
说实话每年毕业季前后我都会被学弟学妹问同样的问题“老师我选的题还能改吗”“这个毕设名额是不是满了”“到底谁把我这个题抢走了”如果你们学校还在用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.jsVue做全栈效率远高于临时抱佛脚学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.cmdPowerShell执行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省了一堆折腾。登录成功的核心操作是签发JWTconst 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/topicsVite会悄悄帮忙转发到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或改用CMDNode接口请求跨域报错前端请求被浏览器拦截配置Vite代理或后端启用CORS数据库连接超时数据库服务未启动或连接参数错误确认MySQL服务核对.env里的host、端口、密码选题名额超卖并发下先查后改改用条件UPDATE原子占座403无权访问角色不匹配或无token检查路由meta.roles与后端requireRole401登录失效token过期或格式错误重新登录确认请求头带Bearer前缀题目A老师改B老师的题缺少归属校验控制器内检查teacher_id req.user.id依赖装完项目跑不起来package-lock丢失或版本漂移锁精确版本提交package-lock.json7.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报错、跨域问题都值了。