说实话,每年毕业季前后,我都会被学弟学妹问同样的问题:“老师我选的题还能改吗?”“这个毕设名额是不是满了?”“到底谁把我这个题抢走了?”如果你们学校还在用Excel汇总选题、靠QQ群接龙选课,那基本就是一团乱账。我做这个基于Node.js + Vue的毕业设计选题管理系统,目的非常朴素——把选题发布、学生申报、教师确认、管理员兜底这条完整流程搬到线上,让所有环节都留痕、可查、不靠人肉统计。
它的核心思路是前后端分离:后端用Node.js的Express提供接口,前端用Vue 3搭页面,MySQL做数据持久化。整篇会从需求拆解讲到数据库设计,再讲Node.js环境配置里那些高频报错、前端关键页面的实现、后端鉴权设计,最后给出一份前后端联调的排查清单。如果你正准备自己做毕设,或者刚入门全栈想找一个能完整落地的小项目练手,这篇直接把能抄的代码、参数和踩坑经验都摊开给你。
1. 先把需求拆干净:这个系统到底要管什么
在做任何代码之前,先把业务方的话翻译成功能。很多毕设项目翻车,不是代码写得烂,是需求没拆透就开始建表。
1.1 三种角色,三种完全不同的操作视角
这个系统的用户不是铁板一块,至少分成三种角色:学生、教师、管理员。
学生关心的是“我能选什么题、哪些还没满、我选了没有”。所以学生端必须提供选题列表、关键词和方向筛选、每人最多选报几个题,以及查看自己申请状态的入口。这里有一个业务细节容易漏:学生选报之后,老师还没确认之前,学生能不能反悔?合理的设计是允许学生在“待审核”状态下撤回申请,一旦老师已经通过,就不能再自行退选,必须要走线下或管理员处理。把这个规则写进需求文档,前端就不会出现“按钮该不该显示”的纠结。
教师关心的则是“我发布了哪些题、每个题被谁选了、我要不要通过”。教师端需要维护自己的题目列表,可以新增选题、修改选题目录、临时关闭某个方向,以及处理学生提交的对接申请。数据上要强调“只看属于我的”,不能把全校的题都铺在老师面前。
管理员是整条链路上的兜底角色。管理员端负责维系比自己级别更高的基础数据:批量导入用户、重置密码、分配导师、查看全局选题进度、发布系统公告,适当情况下还应该能强制干预某条申请。
1.2 选题状态机:一条申请从生到死的完整生命周期
后台系统最忌讳把状态存成字符串然后到处if判断,你要先在脑子里把状态机画出来。
选题本身有生命周期:教师创建后是草稿,确认后变为已发布,期间可以被教师手动关闭,选满之后自动变为已满员,不能继续申报。选报申请也有自己的状态:学生提交后是待审核,教师通过后是已通过,被驳回就是已驳回,学生撤回则是已取消。
| 状态 | 所属实体 | 含义 | 能触发它的动作 |
|---|---|---|---|
| 草稿 | 选题 | 老师编辑中,学生不可见 | 创建、编辑未发布 |
| 已发布 | 选题 | 学生可以申报 | 教师确认发布 |
| 已满员 | 选题 | 达到名额上限,停止申报 | 系统自动变更 |
| 已关闭 | 选题 | 教师临时关闭申报 | 教师手动操作 |
| 待审核 | 申请 | 学生已提交,等待教师处理 | 学生提交申请 |
| 已通过 | 申请 | 老师在规定时间内确认 | 教师通过 |
| 已驳回 | 申请 | 老师拒绝,学生可重新选择 | 教师驳回 |
| 已取消 | 申请 | 学生撤回申请 | 学生在待审核状态撤回 |
强烈建议在数据库里用数字或简短字符串存状态码(比如pending、approved),页面展示时统一映射为中文标签。别小看这个规范,我做过的项目里,状态字段直接存“待审核”三个字导致前端报错的情况已经不止一次。
1.3 非功能需求比功能需求更像“坑”
除了上面的业务功能,还得考虑几个非功能性需求,它们不会出现在功能列表里,但直接影响系统能不能用。
第一是并发。选题开放那一刻,全年级几百人同时刷新页面是常态,选报时如果不好好控制并发,就会出现“名额明明还剩1个,却有5个人同时选成功”的灵异事件。这个问题在后面数据库设计部分会单独展开。
第二是数据可追溯。谁在什么时间发布了哪个题目、谁在什么时间通过了哪个学生,这些记录必须查得到。哪怕只是简单的create_time字段,也比什么都没有强。
第三是权限隔离。学生不能看到教师管理后台菜单,教师不能访问管理员的接口。这个要靠后端的角色中间件和前端的路由守卫一起做,缺一环都不行。
2. 技术选型与整体架构:为什么是 Node.js + Vue
很多人在选题时纠结项目要不要用Spring Boot,其实答案是:取决于谁在写代码。一个熟悉前端的学生,用Node.js+Vue做全栈,效率远高于临时抱佛脚学Spring。
2.1 这个项目为什么不适合传统模板渲染
有人可能会问,既然后端都写接口了,为什么不直接用JSP或者Thymeleaf做整体渲染?答案在交互体验上。选题广场需要实时筛选、分页、卡片式展示;教师端要内联编辑题目信息;管理员端要频繁刷新表格数据。这些交互如果用服务端模板渲染,每做一次筛选就要整页刷新,开发效率和用户体验都很难受。
Vue的优势在于数据驱动视图——选题列表变了,页面自动更新;筛选条件变了,重新请求接口然后替换列表数据。再加上Element Plus这种组件库,表格、表单、弹窗都是现成的,能把大量时间节省出来放在业务逻辑上。另外一个不可忽视的原因:Vue对新手非常友好,数据流直观,社区资料多,遇到问题很容易搜到答案。
2.2 目录结构与一次请求的完整流转
这个项目不是单体应用,我会分成server(后端)和web(前端)两个目录,不要把代码混在一个工程里。
选题管理系统/ ├── server/ # Node.js 后端 │ ├── app.js # Express 入口 │ ├── .env # 环境变量(端口、密钥、数据库) │ ├── routes/ # 路由定义 │ │ ├── auth.js # 登录、注册 │ │ ├── topics.js # 选题相关接口 │ │ └── selections.js # 选报相关接口 │ ├── controllers/ # 控制器,处理业务逻辑 │ ├── models/ # 数据库查询封装 │ ├── middleware/ # 鉴权、错误处理中间件 │ └── utils/ # 工具函数 └── web/ # Vue 前端 ├── index.html ├── vite.config.js # 开发代理配置 ├── src/ │ ├── main.js │ ├── router/ # 路由与守卫 │ ├── stores/ # Pinia 状态管理 │ ├── api/ # axios 接口封装 │ ├── views/ # 页面组件 │ └── components/ # 公共组件 └── package.json一次完整的登录请求是这样流转的:Vue登录页获取用户输入,通过axios发送POST请求到/api/auth/login;后端路由auth.js把请求分发给控制器;控制器校验密码、调用models层查询数据库;拿到用户信息后签一个JWT返回给前端;前端把token存进localStorage,随后在axios拦截器里带上Authorization头。之后每一次请求都走“前端带token → 后端中间件验证身份 → 查询数据 → 返回JSON”这条链路。
3. 数据库设计:五张表把整条选题流程串起来
数据库是这个系统最不能将就的部分。表设计得不好,后面写接口处处别扭;表设计好了,大部分接口其实就是在做查表和改状态。
3.1 用户、选题、选报三张核心表的结构
**用户表(users)**存所有角色,用role字段区分学生、教师、管理员。学生和教师都有学号/工号、姓名、所属学院和专业。密码必须存哈希值,不要存明文。
CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, user_no VARCHAR(20) NOT NULL UNIQUE COMMENT '学号/工号', password_hash VARCHAR(100) NOT NULL, role ENUM('student', 'teacher', 'admin') DEFAULT 'student', name VARCHAR(20) NOT NULL, department VARCHAR(50) COMMENT '学院', major VARCHAR(50) COMMENT '专业', phone VARCHAR(20), email VARCHAR(100), status TINYINT DEFAULT 1 COMMENT '1正常 0禁用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP );**选题表(topics)**记录教师发布的题目,包含题目名称、简介、具体要求、面向方向、招生名额、当前已选人数、状态。这里有一个非常关键的字段:teacher_id,所有教师接口做修改、删除操作时都必须检查操作人是不是这个teacher_id的主人,否则就会出现A老师改了B老师题目的越权问题。
**选报表(selections)**是学生和选题之间的关联表,核心是status字段,表示申请当前处于什么状态。
CREATE TABLE selections ( id INT AUTO_INCREMENT PRIMARY KEY, student_id INT NOT NULL, topic_id INT NOT NULL, status ENUM('pending', 'approved', 'rejected', 'canceled') DEFAULT 'pending', apply_time DATETIME DEFAULT CURRENT_TIMESTAMP, handle_time DATETIME NULL, handle_note VARCHAR(255) COMMENT '教师处理意见', UNIQUE KEY uk_student_topic (student_id, topic_id) );虽然我写的是“五张表”,但实际开发时建议再加公告表(announcements)和学院专业表(departments)。公告用于发布“选题系统明天开放”这类通知;学院专业表用来做筛选器的下拉选项,不要在前端硬编码专业列表,否则后期改数据还得重新发版。
3.2 名额不超卖:用一条 UPDATE 拦住并发
很多新手写选报接口时是这么写的:先查一下selected_count,如果小于max_students就插入一条选报记录,同时把selected_count加1。这个逻辑在低并发下没问题,但放到选题开放那一刻,几百人同时点“申报”,两个请求同时查到剩余名额是1,并且都通过了判断,然后都执行插入,席位就超卖了。
解决方案是把“检查名额”和“占用名额”合并成一条原子操作:
UPDATE topics SET selected_count = selected_count + 1 WHERE id = ? AND selected_count < max_students;执行完看affectedRows,如果是0说明名额已经被人抢光了,直接返回“该选题名额已满”;如果是1说明本次占座成功,再执行选报记录的插入。这个思路跟火车票抢票是一个道理:不是在客户端排队,而是在数据库层面用条件更新保证不会重复扣减。说句实在话,很多学生做的管理系统根本没有这一行SQL,等真的被并发选报冲击时就会现出原型。
3.3 字段类型与索引的小细节
系统不大,但几个细节还是要注意。用户表的user_no一定要加唯一索引,不然批量导入用户时重复数据会让系统数据变得很脏。选题表的teacher_id加普通索引,教师端“查看我发布的题”是高频查询,没有索引的表数据量到几千条时就会有明显卡顿。选报表的student_id加普通索引,学生端“查看我选了什么”同样高频。日期时间字段能不加时区就别加时区,统一用DATETIME,前端展示时再格式化,省掉一堆时间换算的乱子。
4. Node.js 开发环境搭建与 npm 依赖里那些破事
很多同学第一次接触这个项目,被劝退不是卡在业务逻辑上,而是连环境都搭不起来。说白了,Node.js的安装和npm依赖管理就是第一道鬼门关。
4.1 安装与验证:先别急着双击下一步
去Node.js官网下载LTS版本的安装包,别为了尝鲜装最新版。LTS的含义是长期维护版本,生态兼容性最稳。我见过太多人装了个Node 23然后跑Vite项目各种报错,最后发现是版本太新导致依赖不兼容。
双击安装时一路默认即可,但装完之后不要急着写代码,先打开命令行验证两件事:
node -v npm -v如果两个命令都能正常输出版本号,说明核心安装成功了。Node.js安装包默认会把node.exe所在的目录加进系统PATH环境变量,所以理论上开个新终端就能直接用。如果你改了安装路径,或者系统里之前装过别的版本导致命令找不到,就需要手动到“系统属性 → 环境变量 → Path”里添加Node安装目录。这一步操作结束后要重新打开终端才能生效,别问我怎么知道的。
另外,npm默认源在海外,下载依赖的速度可能让你怀疑人生。国内项目建议直接换成国内镜像源:
npm config set registry https://registry.npmmirror.com npm config get registry # 验证是否生效4.2 npm.ps1 无法加载文件的三个解法
这个报错的热度完全在我意料之中:“npm : 无法加载文件 ...\npm.ps1,因为在此系统上禁止运行脚本”。原因一句话讲明白:Windows的PowerShell默认执行策略是Restricted,不允许运行.ps1脚本文件,而npm命令本质是通过npm.ps1脚本去执行的。
解法有三个,按推荐顺序排列:
解法一:修改当前用户的执行策略。用管理员身份打开PowerShell,运行:
Set-ExecutionPolicy RemoteSigned -Scope CurrentUserRemoteSigned意思是本地创建的脚本可以运行,远程下载的脚本必须有数字签名。这个方案一劳永逸,改完就不用再碰这个报错。
解法二:改用CMD。如果你只是想把环境搭起来,直接在文件资源管理器地址栏输入cmd回车,然后在该命令行窗口里运行npm -v,你会发现一切正常。原理很简单:CMD执行npm.cmd,PowerShell执行npm.ps1,执行策略管不到CMD。
解法三:通过package.json的scripts运行。很多时候你根本不需要手动执行npm命令,用Vite脚手架创建的项目,直接运行npm run dev内部会调用对应的脚本。但因为脚本链路里也有PowerShell的参与,这个解法并不总是奏效,所以最稳的还是解法一。
4.3 统一依赖版本,避免“我这能跑你那不行”
依赖这关最磨人的不是装不上,而是每个人装出的版本不一样。你本地跑得好好的,代码发给室友跑就报错,十有八九是依赖版本漂移了。
解决方案:锁定大版本。package.json里不要出现^加通配符的情况,直接锁成精确版本,比如"vue": "^3.4.0"这种写法在团队协作里尽量少用。package-lock.json一定要提交到仓库,它记录的是整棵依赖树的具体版本,别人拉下来执行npm install时会安装完全相同的依赖版本,“我这能跑你那不行”的问题能少一大半。
如果你遇到npm install报peer dependency冲突,比如某个组件库要求Vue 3.2以上而你的项目锁了3.0,先别急着乱升级。看一下报错信息里要求的版本范围,再决定是升级主依赖还是改用npm install --legacy-peer-deps跳过严格检查。后者属于临时绕过,拖到后面仍然可能出兼容问题,所以我一般建议能升级就升级。
如果确实需要升级Node.js大版本,也别去官网重装,用nvm-windows做版本管理更省心。装上之后nvm install 20、nvm use 20就能自由切换版本,做多个项目时真的离不开它。
5. Vue 前端:路由、状态管理与选题广场的实现
后端接口写得再完善,前端如果乱成一团,这个系统还是没法用。Vue这部分我会挑三个关键环节讲:路由守卫、接口封装、以及选题广场的交互实现。
5.1 路由守卫 + 动态菜单:三种角色只看到一个入口
系统的路由不能一个角色通吃。学生打开后台应该看到“选题广场”和“我的选题”,教师打开应该看到“题目管理”和“申请审核”,管理员才能看到“用户管理”和“数据概览”。
做法是在路由定义里给每个页面标上允许访问的角色:
{ path: '/topics', name: 'Topics', component: () => import('@/views/student/TopicList.vue'), meta: { roles: ['student', 'admin'] } }然后在Vue Router的全局前置守卫里做拦截:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') const userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}') if (!token && to.path !== '/login') { next('/login') } else if (to.meta.roles && !to.meta.roles.includes(userInfo.role)) { next('/403') } else { next() } })这里的核心逻辑是:没登录只能去登录页,角色不匹配统一跳403。菜单用v-if根据角色的不同渲染不同入口,而路由守卫负责兜底——就算用户猜到了URL路径,没有权限照样进不去。
5.2 选题广场:筛选、分页与防重复提交
选题广场是这个系统真正有业务含金量的页面。它要支持按关键字、按方向、按指导老师筛选,还要分页展示,并且跟后端接口的参数一一对应。
我用Element Plus的表格加卡片混合展示:卡片展示选题名称和简介,详情弹窗展示具体要求。筛选条件变化时重新请求接口,每次请求带这样的参数:
// api/topic.js export function fetchTopics(params) { return request.get('/topics', { params }) } // 页面里的调用 const params = reactive({ keyword: '', category: '', page: 1, pageSize: 10 }) function loadTopics() { fetchTopics(params).then(res => { topicList.value = res.data.list total.value = res.data.total }) }这里有个实战细节很多人会忽略:学生点击“申报”按钮之后,要立刻禁用按钮并显示loading。否则网络慢的时候学生连点三次,就会发出三个一模一样的选报请求。后端虽然可以靠唯一索引兜底,但前端能拦住的事不要让后端去擦屁股。
我自己的习惯是提交成功之后把按钮文案改成“已申报”,并置灰,状态从后端返回的实际状态为准,不靠前端临时揣测。
5.3 请求拦截器与401统一处理
axios需要统一封装,不能每个页面写一遍请求代码。在utils/request.js里配置拦截器:
request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) request.interceptors.response.use( res => res.data, err => { if (err.response.status === 401) { localStorage.clear() location.href = '/login' } return Promise.reject(err) } )拦截器本质上是个总闸门:所有请求自动带token,所有401响应统一踢回登录页。这样业务页面里就不会到处写“清localStorage、跳登录”的重复代码。
6. 后端接口与鉴权:JWT 登录和控制访问范围
前端的壳再漂亮,后端的接口才是业务正确性的保证。重点讲登录的密码处理和角色权限中间件。
6.1 登录接口的实现与密码存储
密码存储是安全底线。我没有直接把数据库里的密码哈希值写进博文,但你可以记住这个原则:绝对不要存明文密码。登录密码校验时用bcryptjs比对哈希值,注册或重置密码时用bcryptjs.hashSync生成新哈希。bcryptjs是纯JavaScript实现,不会像原生bcrypt那样在Windows上需要编译node-gyp,省了一堆折腾。
登录成功的核心操作是签发JWT:
const jwt = require('jsonwebtoken') const token = jwt.sign( { id: user.id, role: user.role }, process.env.JWT_SECRET, { expiresIn: '2h' } )process.env.JWT_SECRET放在项目的.env文件里,不要硬编码到代码中,更不要传到Git仓库。因为一旦密钥泄露,攻击者可以伪造任意身份的token。有效期建议2小时左右,但学生的使用场景是一顿操作写完就关掉页面,过长反而增加被盗用的风险。
6.2 角色权限中间件:用一个 requireRole 控制所有接口
后端不能只验登录状态,还要验角色。我封装了两个中间件,一个负责验证token,一个负责校验角色:
// middleware/auth.js function authenticate(req, res, next) { const authHeader = req.headers.authorization if (!authHeader) return res.status(401).json({ message: '未登录' }) const token = authHeader.split(' ')[1] try { const payload = jwt.verify(token, process.env.JWT_SECRET) req.user = payload next() } catch { return res.status(401).json({ message: '登录失效' }) } } function requireRole(...roles) { return (req, res, next) => { if (!roles.includes(req.user.role)) { return res.status(403).json({ message: '无权访问' }) } next() } }使用的时候组合起来:
router.put('/topics/:id', authenticate, requireRole('teacher'), updateTopic)在updateTopic这个控制器内部,还要再检查一次topic.teacher_id === req.user.id。为什么?因为requireRole只能保证“你是老师”,不能保证“这道题是你的”。不写归属校验,A老师把B老师的题目改成自己的名字发布了,这系统就乱套了。业务越权检查永远要放在同一次请求的最前面执行,不要让未授权的请求进入写库流程。
6.3 后端接口的响应格式与错误处理
给前端返回的数据结构要固定,不要一会儿返回数组一会儿返回对象。我习惯统一为:
{ "code": 200, "data": {}, "message": "ok" }业务错误统一用code区分,HTTP状态码只表示“请求有没有正常到达后端”。一个全局错误处理中间件负责兜底:
app.use((err, req, res, next) => { console.error(err) res.status(500).json({ code: 500, message: '服务器内部错误' }) })别小看这一步:没有全局错误处理,数据库一断,Express默认返回的HTML错误页把前端直接打懵,排查半天才知道是SQL问题。
7. 前后端联调的问题排查与实用清单
这个章节是给我自己踩过的坑做结论汇编的。前后端分离项目,接口写好了页面调不通,一半的问题出在联调环境上。
7.1 跨域、端口占用与404三大高频问题
开发时前端跑在5173端口,后端跑在3000端口,浏览器默认会拦截跨域请求。解决办法不是在后端暴力的加cors()就完事,而是用Vite的开发代理把前端的/api请求转发到后端:
// vite.config.js export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true } } } })配置好代理后,前端请求/api/topics,Vite会悄悄帮忙转发到http://localhost:3000/api/topics,解决了跨域问题。
端口占用也遇到过很多次。后端突然起不来,提示EADDRINUSE: address already in use :::3000,说明有进程占着3000端口。Windows下排查:
netstat -ano | findstr :3000 taskkill /pid PID号 /f接口404则需要检查两件事:一是路由文件是否在app.js里挂载,比如写了app.use('/api/topics', topicsRouter)但topicsRouter却没有导出,请求自然落到404;二是控制器里是否定了router.get('/', ...),如果你访问的是/api/topics/而不是/api/topics,路径不匹配也会404。
7.2 高频报错速查表
| 故障现象 | 根本原因 | 解决思路 |
|---|---|---|
npm.ps1禁止运行 | PowerShell执行策略限制 | Set-ExecutionPolicy RemoteSigned -Scope CurrentUser或改用CMD |
| Node接口请求跨域报错 | 前端请求被浏览器拦截 | 配置Vite代理或后端启用CORS |
| 数据库连接超时 | 数据库服务未启动或连接参数错误 | 确认MySQL服务,核对.env里的host、端口、密码 |
| 选题名额超卖 | 并发下先查后改 | 改用条件UPDATE原子占座 |
| 403无权访问 | 角色不匹配或无token | 检查路由meta.roles与后端requireRole |
| 401登录失效 | token过期或格式错误 | 重新登录,确认请求头带Bearer前缀 |
| 题目A老师改B老师的题 | 缺少归属校验 | 控制器内检查teacher_id === req.user.id |
| 依赖装完项目跑不起来 | package-lock丢失或版本漂移 | 锁精确版本,提交package-lock.json |
7.3 一点部署上的提醒
系统本地跑通之后,如果要把node server跑起来对外提供服务,不要裸跑node app.js。用pm2做进程守护,npm install -g pm2,然后pm2 start app.js --name topic-system。哪怕进程崩溃,pm2也会自动把它拉起来。MySQL连接配置上,不要用localhost混合127.0.0.1,选一个,两个指向的socket方式不同,有时候就会莫名其妙连不上。
这个选题管理系统做下来,我的一个深刻体会是:系统的复杂度不在代码行数,而在业务规则是否理清、并发场景是否考虑、角色边界是否收紧。很多人写管理系统,界面堆得很满,但没有状态机、没有越权控制、没有并发保护,上线一用就露馅。如果你正在做类似的系统,把我的这几条经验用进去,答辩时老师问“为什么这么设计”,你至少能讲出三层道理。多说一句,真到了开放选题那天,数据库并发扛住了、权限没出乱子、学生反馈“系统用起来顺手”,那一刻你会觉得前面调的那些npm报错、跨域问题,都值了。