
1. 项目整体设计与技术选型1.1 为什么是Node.js Vue这套组合先说结论做选课系统这类典型的CRUD加业务规则校验的项目Node.js Vue是目前效率最高、最不容易把自己绕晕的组合之一。很多人在选型时会纠结用不用Spring Boot。我的观点很明确如果这个项目的核心目标是快速上线、方便扩展、让前后端代码风格统一Node.js比Spring Boot更合适。原因有三点。第一JavaScript全栈意味着前后端共用一套语言体系前端同学不用学Java后端同学也不用在切换语言上浪费精力。第二Node.js的异步IO模型对选课这类高并发读多写少的场景非常友好虽然节点服务器是单线程但处理数据库查询这类IO任务时完全不阻塞。第三整个项目从初始化到跑起来十分钟内可以搞定这种快速反馈对开发体验的提升是巨大的。Vue这边我选的是Vue 3 Element Plus Pinia这套现代组合。Vue 3的Composition API在写业务逻辑时比Options API舒服太多尤其是选课页面这类需要管理多个联动状态的地方。Element Plus提供现成的表格、表单、弹窗、步骤条组件选课系统的界面基本不用从头写样式。Pinia相比Vuex少了很多模板代码维护一个用户状态、一个选课状态就够了。这套组合能解决的问题非常具体管理员维护课程库学生按学期选课、退课、查看课表、提交课程评价教师查看选课名单和收到的评价反馈。整个过程涉及权限控制、选课冲突检测、容量限制、防重复评价这几个核心难点这套技术栈都有成熟的应对方案。1.2 功能模块与角色权限拆解选课系统表面上看是几个页面但梳理完需求之后你会发现本质上是三套完全不同的业务流程挂在同一个系统里。学生端是本系统最复杂的部分。学生登录后看到的是当前学期的可选课程列表列表带筛选条件课程类型、学分范围、上课时间。点选课后系统要做三重校验是否与已有课程时间冲突、课程是否还有余量、是否已经选过这门课。选课成功后生成课表视图按星期几和节次排列。学期结束后进入评价模块学生对已修课程打分并写评语。教师端的流程相对简单查看自己名下课程的选课名单包括学生学号、姓名、专业、选课时间查看课程评价汇总包括平均分、各维度得分分布、文字评语列表。管理员端负责基础数据维护用户管理教师账号、学生账号的增删改查、课程管理新建课程、设置容量和上课时间、指定授课教师、学期管理开启/关闭选课窗口、设定选课时间段。三条流程交织的地方是权限校验。我用了JWT做身份认证在Token里写入用户ID和角色后端中间件统一拦截请求角色不匹配直接返回403。Vue Router端也做了菜单级权限控制不同角色登录后看到的导航菜单不一样。1.3 技术栈版本选型项目落地时的具体版本选择直接影响后续开发和部署。我实际使用的是Node.js 18.x LTS版本Express 4.x作为后端框架MySQL 8.0作为数据库使用mysql2驱动Vue 3.4 Vite 5 vue-router 4 Pinia 2Element Plus 2.x组件库Axios做HTTP请求Node.js 18相比16和20的优势在于LTS稳定性且自带fetch API后续写脚本调用第三方接口时不需要额外引入请求库。Express 4到现在依然是Node后端最稳的框架Express 5还在beta阶段不建议生产环境冒险。Vite 5初始化Vue项目比webpack时代快一个数量级开发模式的HMR几乎是秒级更新改完代码一刷新就能看到效果。Element Plus的按需导入配置好之后打包体积能控制在合理范围内。提示个人开发或毕设项目优先用LTS版本不要追新版本。Vue 3.5已经发布但3.4生态下的组件库兼容性经过大量项目验证出问题的概率更低。2. 数据库设计与后端接口核心要点2.1 五张核心表的字段设计与关联关系选课系统的数据库设计是整个项目的地基。我用了五张表每张表的字段设计都经过实际业务验证。users用户表字段名类型说明idBIGINT主键自增usernameVARCHAR(50)登录账号唯一索引passwordVARCHAR(255)密码bcrypt加密存储roleTINYINT1学生2教师3管理员real_nameVARCHAR(50)真实姓名student_noVARCHAR(20)学号学生角色teacher_noVARCHAR(20)工号教师角色密码一定要用bcrypt加密不要用MD5。MD5撞库太容易了即使加盐也挡不住GPU暴力破解。bcrypt自带盐值机制每次加密结果不同安全性完全够用。courses课程表字段名类型说明idBIGINT主键course_codeVARCHAR(20)课程编号如CS101course_nameVARCHAR(100)课程名称teacher_idBIGINT授课教师ID关联users表creditDECIMAL(3,1)学分如2.0capacityINT课程容量上限selected_countINT当前已选人数scheduleVARCHAR(100)上课时间格式见下semesterVARCHAR(20)所属学期如2025-2026-1schedule字段我统一用星期-节次格式比如1-3,1-4代表周一第3、4节3-7代表周三第7节。多段上课时间用逗号分隔。这个格式简单可靠后端解析字符串做冲突检测前端解析后渲染课表是投入产出比最高的方案。course_selections选课表CREATE TABLE course_selections ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, course_id BIGINT NOT NULL, status TINYINT DEFAULT 1 COMMENT 1选课2退课3已完成, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_selection (student_id, course_id) );唯一索引uk_selection是防止重复选课的关键。就算前端没有做校验后端并发请求打进来数据库层面的唯一约束也能兜底。course_evaluations课程评价表CREATE TABLE course_evaluations ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL, student_id BIGINT NOT NULL, rating TINYINT NOT NULL COMMENT 1-5星, suggestion VARCHAR(500) COMMENT 文字评价, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_evaluation (course_id, student_id) );teaching_plan教学计划表用于关联教师和课程当一门课可能由多位老师任教时不会出现数据冗余。对于当前这种一门课一位老师的情况courses.teacher_id字段已经够用这个表可以暂缓。五张表的关系是一目了然的课程属于教师学生通过选课表和课程建立多对多关系通过评价表对课程打分。数据链路清晰写SQL和调试都省事。2.2 选课冲突检测的算法设计与实现选课系统的核心难点不在CRUD而在选课瞬间的业务校验逻辑。常见的问题是学生选了两门时间重叠的课或者课程只剩最后一个名额时多个学生同时点选课。冲突检测我用的是一个函数解析上课时间字符串。学生已有的选课记录取出所有课程的schedule组成一个时间占用列表解析成数组格式例如[{week: 1, sections: [3,4]}, {week: 3, sections: [7]}]。新课的schedule按同样方式解析后逐一比对发现有重叠就返回冲突提示。这里的核心逻辑是只要两个课程存在同星期且有节次交集就算冲突。边界情况比如1-3,1-4和1-4,1-5交集是第4节也算冲突。容量控制的后端实现我用的是数据库条件更新。选课时执行UPDATE courses SET selected_count selected_count 1 WHERE id ? AND selected_count capacity然后检查affectedRows等于1说明更新成功等于0说明没名额了。这个写法把“查询余量”和“扣减名额”两个动作合并成一个原子操作避免了并发场景下的超卖问题。加上事务包住选课记录插入就能保证数据一致性。注意不要在代码里先SELECT查询余量再UPDATE。这种“先查后改”在高并发下会出问题两个请求同时查到余量1然后同时UPDATE结果选课人数超过容量。这是一个经典的并发陷阱。整个选课接口的流程是前端提交课程ID后端先验证课程存在且处于选课期再验证学生没有选过该课靠唯一索引兜底然后解析时间做冲突检测最后执行容量扣减和选课记录插入全部通过事务包裹。任何一个环节校验失败就回滚返回具体的错误信息。2.3 课程评价模块的防重复与统计设计课程评价的业务规则是学生只能对自己已选且状态为已完成的课程进行评价每门课只能评一次。接口设计上评价表的一节课最多评价1次这里验证了点位。我先把教学班表course_selections关联课程的status置为已完成然后在课程绑定模块中触发评价资格。实现上评价接口执行时先查出当前学生对该课程的选课记录确认课程是已完成状态再尝试插入评价记录。由于course_evaluations表有uk_evaluation唯一索引即使两个请求同时进来数据库也只会允许一条插入成功另一条会抛出Duplicate entry错误被catch之后返回友好提示“你已经评价过这门课程了”。评价展示端教师可以按课程维度查看平均分和评语列表。SQL聚合查询SELECT course_id, AVG(rating) AS avg_rating, COUNT(*) AS eval_count FROM course_evaluations GROUP BY course_id3. 前端Vue项目搭建与核心页面实操3.1 从脚手架到项目骨架5分钟初始化项目前端项目我推荐用Vite的官方脚手架创建。命令是npm create vuelatest这个命令会引导你选择是否启用TypeScript、JSX、Router、Pinia、ESLint等选项。选了Vue Router和Pinia之后脚手架会自动生成src/router和src/stores目录结构省去手动深度配置的重复劳动。项目目录骨架我按模块划分而不是按文件类型划分对大项目更好维护src/ ├── api/ # 接口请求封装 │ ├── auth.js # 登录相关接口 │ ├── course.js # 课程与选课接口 │ └── evaluation.js # 评价相关接口 ├── assets/ # 静态资源 ├── components/ # 公共组件 │ ├── CourseCard.vue │ ├── ScheduleTable.vue │ └── Pagination.vue ├── router/ # 路由配置 ├── stores/ # Pinia状态管理 │ ├── user.js │ └── selection.js ├── views/ # 页面级组件 │ ├── login/ │ ├── student/ │ ├── teacher/ │ └── admin/ ├── utils/ # 工具函数 │ ├── request.js # axios实例 │ └── schedule.js # 时间冲突检测 └── App.vue按模块划分的好处是每个业务域的自闭环比较清晰找一个功能对应的文件时定位成本很高。页面级组件和公共组件分开也很重要——CourseCard.vue在多处复用放公共组件目录选课页面的逻辑视图放在views下面严格分层。3.2 路由配置与登录守卫vue-router实战路由这边我遇到过一个很典型的问题如何管理需要登录才能访问的页面。答案是全局前置守卫。router.beforeEach((to, from, next) { const userStore useUserStore() // 白名单页面如登录页 if (to.path /login) { next() return } // 未登录重定向到登录页 if (!userStore.token) { next(/login) return } // 角色权限校验路由meta中配置可访问角色用户角色不匹配就无权限 const allowedRoles to.meta.roles if (allowedRoles !allowedRoles.includes(userStore.role)) { next(/403) return } next() })路由表设计上我尽量使用了component路由懒加载() import(...)每个页面按路由单独打包首屏加载速度明显提升。另外路由meta中放title和roles导航栏的菜单数据可以直接从路由表生成面包屑也据此自动生成不用各写一套。3.3 选课页面与课表组件的实现细节选课页面是整个前端最重要的业务页面碰到的第一个坎是课程数据的响应式状态管理逻辑。我建了一个selection store来集中管理课程列表、筛选条件、已选课程ID集。核心状态如下export const useSelectionStore defineStore(selection, { state: () ({ courseList: [], mySelection: [], loading: false, filter: { keyword: , credit: null, searchSchedule: } }), getters: { availableCourses: (state) state.courseList.filter(...) } })选课操作调API后除了更新后端数据还要同步更新前端状态——把这个课程的selected_count加1并把它加入mySelection。这个状态同步如果不做用户选完课回到列表发现课程余量没变就会误以为没选上。课表组件是我单独抽出来的公共组件。输入是mySelection课程数组输出一个7列周一至周日乘12行第1至第12节的二维表格。渲染逻辑里最有趣的部分是合并单元格——一门课如果占两节课需要合并连续格子形成一个跨行区块。实现上用CSS Grid比较方便遍历课程列表动态计算grid-row和grid-column属性。template div classschedule-grid div classgrid-header星期一/div div classgrid-header星期二/div !-- ... -- div v-forcourse in courses :keycourse.id classcourse-block :stylegetBlockStyle(course) {{ course.course_name }} p{{ course.teacher_name }}/p /div /div /templategetBlockStyle(course)解析schedule字符串返回对应的位置和跨度样式。这块逻辑不复杂但计算量不小每门课两节课时段的格子颜色要相互区分——我用一个按课程ID取模的色板维持界面不会大面积重绘。3.4 课程评价与组件通信props、事件与插槽课程评价模块的前端实现涉及Vue组件通信的三种核心方式。课程卡片组件CourseCard.vue通过props接收课程对象内部展示课程信息。评价按钮点击后向外触发emit(evaluate, course)事件父组件监听到后弹窗展示评价表单。这是标准的props down, events up模式最简单可靠。弹窗里的评价表单用了Element Plus的el-rate组件做五星评分el-input存文字建议。提交时调用评价API成功后把当前课程标记为已评价状态防止用户重复弹窗。插槽的使用场景在于课程卡片列表的底部操作区。不同角色在同一个课程卡片上显示的按钮不同——学生看到选课/退课管理员看到编辑/删除。我在CourseCard.vue里留了一个#actions插槽外层根据角色动态填充不同按钮。通用卡片风格和专属操作互不干扰这是组件设计的常见思路。CourseCard :coursecourse template #actions !-- 学生视角 -- el-button clickhandleSelect(course)选课/el-button el-button v-ifselected(course.id) clickhandleDrop(course)退课/el-button !-- 管理员视角 -- el-button v-ifuserStore.role 3 typedanger clickhandleDelete(course)删除/el-button /template /CourseCard这种插槽设计让组件复用价值直接翻倍整个系统只需要维护一套课程卡片样式不同角色页面复用同一个组件。4. 环境配置、npm与常见报错实录4.1 Node.js安装与环境变量配套很多新手卡在项目运行的第一步Node.js装了但装不上或者装好了到处报错。Node.js安装时最大的坑是环境变量。Windows下安装包默认会勾选“添加到PATH”但如果你用了解压版或者自定义目录就得手动配置。配置方法是打开系统属性编辑Path变量加入Node.js安装目录比如C:\Program Files\nodejs\。配置完重新开一个终端窗口运行node -v验证。如果显示版本号了说明安装成功。还有一个细节Node.js自带npm安装Node时npm也装好了不用单独安装。npm的全局包目录默认在C:\Users\你的用户名\AppData\Roaming\npm这个路径也在PATH中否则全局安装的vue/cli、vite等工具无法识别。Mac和Linux环境更方便我通常直接用nvm管理多个Node版本。比如电脑上有老项目需要Node 14新项目用Node 18手动切版本之前nvm能保留信息不用反复卸载重装。4.2 npm镜像源与PowerShell执行策略两步走npm在国内的默认源速度很慢有时候装个依赖要卡好几分钟。解决方案是换镜像源npm config set registry https://registry.npmmirror.com验证是否生效npm config get registry还有更大坑是Windows PowerShell环境下的npm脚本执行权限。很多新手在第一次全局安装完脚手架或者运行npm run dev时会看到这个报错npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1因为在此系统上禁止运行脚本原因很直接Windows PowerShell默认执行策略是Restricted禁止运行任何.ps1脚本文件。解决方案是以管理员身份打开PowerShell执行Set-ExecutionPolicy RemoteSigned执行策略改成RemoteSigned后本地编写的脚本可以运行从网络下载的脚本必须有签名才能运行安全性有基本保障。改完之后npm命令顺滑了。注意如果你用的是cmd或Git Bash终端不会触发.ps1执行策略问题——因为执行策略只对PowerShell生效。很多人推荐用Git Bash操作Node项目这就是原因之一。4.3 跨域问题的完整解决方案与axios拦截器前后端分离项目必然遇到跨域问题。前端跑在http://localhost:5173后端跑在http://localhost:3000浏览器默认禁止跨域请求。我的解决方案是后端启用CORS中间件。用cors包一行搞定const cors require(cors) app.use(cors())开发环境这样配置够用。生产环境建议按白名单开放app.use(cors({ origin: [https://your-frontend-domain.com], credentials: true }))axios配置拦截器也是必须的。请求拦截器统一注入Token响应拦截器统一处理错误状态码。// utils/request.js import axios from axios const request axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器携带token request.interceptors.request.use(config { const userStore useUserStore() if (userStore.token) { config.headers.Authorization Bearer ${userStore.token} } return config }) // 响应拦截器统一处理错误 request.interceptors.response.use( response response.data, error { if (error.response?.status 401) { // 登录过期跳转登录页 router.push(/login) } ElMessage.error(error.response?.data?.message || 请求失败) return Promise.reject(error) } )如果前端用开发代理也可以用Vite的proxy配置解决跨域——修改vite.config.jsexport default defineConfig({ server: { proxy: { /api: { target: http://localhost:3000, changeOrigin: true, rewrite: path path.replace(/^\/api/, ) } } } })后端CORS和前端代理这两种方案开发环境我推荐前端代理免去后端起CORS配置的麻烦上线用Nginx反代。生产环境如果前端静态文件由Nginx托管同域访问就不会有跨域问题。5. 排查笔记从开发到部署的避坑清单5.1 并发选课的数据一致性保障我开发时遇到一个典型场景一门只剩一个名额的课程两个学生同时点选课。日志显示两个人拿到的都是成功响应但数据库里选课记录多了一条——课容量超出1人。排查后发现问题出在选课接口的SQL执行顺序上。原代码是const [course] await db.query(SELECT * FROM courses WHERE id ?, [courseId]) if (course.selected_count course.capacity) { return res.status(400).json({ message: 课程已满 }) } await db.query(INSERT INTO course_selections ...) await db.query(UPDATE courses SET selected_count selected_count 1 WHERE id ?, [courseId])先查再更新两个请求都查到了selected_count 99容量100都没触发“课程已满”的返回然后都执行了插入和更新结果course选课记录两条被唯一索引拦截了一条但selected_count被更新成101和实际选课人数对不上了。修复方案是将更新容量和插入选课记录放在同一个事务里且将容量检查改为条件更新UPDATE courses SET selected_count selected_count 1 WHERE id ? AND selected_count capacity;影响行数为0说明名额不足事务回滚。这个方案在MySQL的高并发行之下依然有效——因为UPDATE语句本身会加行锁两个并发UPDATE只有一个会成功。5.2 全流程常见报错速查表报错信息原因解决方案Cannot find module express依赖未安装或路径错误确认在项目根目录运行npm installEADDRINUSE: address already in use :::3000端口被占用netstat -ano | findstr 3000找到PIDtaskkill /PID 进程号 /Fnpm ERR! ERESOLVE unable to resolve dependency tree依赖版本冲突尝试npm install --legacy-peer-depsFailed to fetch类错误网络问题或npm镜像源失效重新设置镜像源npm config set registry https://registry.npmmirror.comDuplicate entry xxx for key uk_selection重复选课数据库唯一索引拦截后端接口捕获SQL错误返回友好提示或插入前先查询选课记录TypeError: Cannot read properties of undefined (reading xxx)后端返回数据结构与前端预期不匹配打印后端返回值排查字段路径SyntaxError: Unexpected token 后端返回了HTML而不是JSON检查接口路径是否有误或前端是否没有加/api前缀failed to load tsconfig vue/tsconfig/tsconfig.web.json脚手架TS配置路径不对检查tsconfig.json中的extends路径或者直接删除TS相关配置每个项目开发到最后有3个最容易反复翻车的地方端口冲突、跨域配置、数据字段名不统一。建议在项目根目录建一个README.md把启动命令、端口号、接口文档链接写清楚防止几个月后自己回来看不懂项目。5.3 课程资源播放的额外能力m3u8流媒体如果后续需要扩展“课程视频回放”功能可以考虑用m3u8流媒体播放。很多高校的课程资源以m3u8切片形式存储在服务器上浏览器原生不支持HLS协议需要引入hls.js来播放。npm install hls.jsVue组件中用hls.js播放的思路是在mounted钩子里初始化播放器把.m3u8地址喂给播放器监听Hls.Events.MANIFEST_PARSED事件后执行播放。template video refvideoPlayer controls classvideo-player/video /template script setup import { ref, onMounted } from vue import Hls from hls.js const videoPlayer ref(null) const videoUrl ref(https://example.com/course/lesson1.m3u8) onMounted(() { if (Hls.isSupported()) { const hls new Hls() hls.loadSource(videoUrl.value) hls.attachMedia(videoPlayer.value) hls.on(Hls.Events.MANIFEST_PARSED, () { videoPlayer.value.play() }) } }) /script如果视频用video.js也可以但hls.js更轻量vue生态里适配更直接。播放器样式、进度条、清晰度切换这些细节都可以用原生HTML5 video属性控制课程回放功能扩展起来非常顺手。个人实操中的几个体会这个项目整套做下来我最大的感受是选课系统看着不起眼真正写起来需要考虑的边界情况非常多。高峰期并发选课的压测评价模块的幂等设计权限控制的前后端双重校验每一块都需要提前规划临时补救会出各种问题。如果让我重新做一次我会先花一个小时把数据库表结构和接口文档定义清楚再动手写代码。前面的设计多花一小时后续开发能少加三天班。尤其是schedule字段的格式一开始定好标准前后端就按一个标准解析后期不会来回改。另外一个切身建议是保留一套压力测试脚本。写一个简单的Node脚本模拟50个并发选课请求验证容量扣减和冲突检测是否真正可靠。这个脚本调试了不少隐蔽问题比如之前发现的并发超卖就是从压测里抓出来的。没有压测这些问题上线前都发现不了。如果想要更快的脚手架体验可以提前把Vue的页面模板、axios请求封装、路由守卫这些通用代码整理成模板新项目起步会非常快。我目前的习惯是维护一份自己的项目模板新项目直接从模板fork熟悉度比用官方脚手架高很多改起来也更顺手。