每年毕业季,指导老师手里那几个“常青树”题目里,绝对少不了“学生选课成绩管理系统”。这个题目看着不起眼,却年年翻新、年年有人踩坑。表面上是“学生登录选课、老师录成绩、管理员管数据”三件套,但真正动手时,从数据库设计到接口规划,再到前端页面联动,每一步都能折腾掉半条命。尤其是现在前后端分离成了默认姿势,ThinkPHP + Vue 这个组合几乎成了课设和毕设的“标准答案”。
这篇文章我打算把这个系统从需求拆解到落地部署完整讲透。我默认你是有一定PHP和JavaScript基础、但还没完整做过一个前后端分离项目的同学,或者正在为选课系统焦头烂额、想找一份能“抄”又能讲清楚原理的参考方案。我会重点讲清楚三件事:选课业务里最容易翻车的并发和去重问题、成绩管理模块里涉及的角色权限边界,以及前后端分离后接口怎么设计才不返工。
1. 系统整体设计与技术选型思路
1.1 为什么是 ThinkPHP + Vue,而不是别的组合
先说后端。ThinkPHP 是国内使用率极高的 PHP 框架,它的文档是全中文的,社区问答积累非常厚,遇到问题基本能搜到现成答案。对做课设和毕设的同学来说,这比 Symfony 或 Laravel 的学习曲线友好太多。ThinkPHP 6 以后完全转向了声明式路由和中间件模式,代码风格更接近现代框架,写出来的东西即便拿到面试场合也不算丢人。我建议新项目直接上 ThinkPHP 6 或 8,不要碰 3.2 之类的老版本——那些版本连 PHP 8 的兼容都成问题,装完扩展直接白屏,纯属给自己挖坑。
再说前端。Vue 在国内前端圈子的统治力不用多讲,它的响应式数据绑定和单文件组件机制,特别适合选课系统这种“操作密集、状态多”的管理后台。选课大厅要实时刷新余量、课表要按周次渲染、成绩单要做汇总统计,这些用原生 jQuery 写会非常痛苦,但放到 Vue 的 data 里就只是几个 computed 和 watch 的事。如果完全没接触过 Vue,建议先花一个下午把官方文档的“基础篇”过一遍,知道 v-model、v-for、computed、methods 这几个核心概念就足够开工了。
1.2 核心模块划分:不是只有“选课”和“成绩”那么简单
很多同学拿到题目直接开始建表,结果做着做着发现逻辑全拧在一起。我习惯先把系统拆成四个边界清晰的模块,每个模块独立设计接口,后期联调能省一半时间。
- 认证与权限模块:登录、退出、身份识别。这个模块决定“谁能进系统、能干什么”。我的方案是用 JWT(JSON Web Token)做无状态认证,后端签发 token,前端每次请求带上,通过中间件解析身份。
- 选课业务模块:课程列表、选课、退选、选课结果查询。这是整个系统最核心、也是最需要细抠的部分,涉及并发、冲突和事务处理。
- 成绩管理模块:成绩录入、成绩修改、成绩查询、成绩统计。这个模块有明确的角色边界——老师只能录自己课程的分数,学生只能看自己的成绩,管理员拥有全部权限但不能随意改分,需要记录操作日志。
- 基础信息模块:学生信息、教师信息、课程信息的增删改查。这个模块偏 CRUD,技术上没难度,但字段设计直接影响前面三个模块的工作量。
1.3 前后端分离后的目录结构长什么样
我用前后端分离的方式组织代码:后端项目放 API 接口,前端项目放页面展示。这样做的好处是毕业设计答辩时,你可以理直气壮地说“我用了当前企业主流的开发模式”。具体目录结构如下:
project/ ├── server/ # ThinkPHP 后端项目 │ ├── app/ │ │ ├── controller/ # 控制器:接收请求、调用逻辑 │ │ ├── model/ # 模型:数据表映射 │ │ ├── middleware/ # 中间件:认证、权限拦截 │ │ └── validate/ # 验证器:参数校验 │ ├── route/ # 路由定义 │ └── config/ # 数据库等配置 └── web/ # Vue 前端项目 ├── src/ │ ├── api/ # 接口请求封装 │ ├── views/ # 页面组件 │ ├── router/ # 前端路由 │ ├── store/ # 全局状态管理 │ └── components/ # 可复用组件 └── package.json这个结构把我的职责边界划得很清楚。后端只负责输出 JSON,前端只负责渲染和交互。联调时只要接口文档写清楚,两边可以并行开发,不用像传统 PHP 模板开发那样等后端渲染完才能看到页面效果。
2. 数据库设计:表结构决定业务逻辑的上限
2.1 核心表拆解与字段说明
数据库设计是选课系统最不能省步骤的环节。我见过很多翻车案例,共通毛病是“一张用户表走天下”,角色靠数字硬编码,结果写着写着就分不清谁是谁。我的方案拆成五张核心业务表加一张扩展表:
- student(学生表):student_id(学号,主键)、name、gender、class_name、major、phone、create_time。
- teacher(教师表):teacher_id(工号,主键)、name、title、department、phone、create_time。
- course(课程表):course_id(课程编号,主键)、course_name、credit、course_time、max_student、selected_count、teacher_id、create_time。这里的 selected_count 是冗余字段,用来记录当前已选人数,避免每次查选课状态都要 count 一遍选课记录表。
- student_course(选课记录表):id(自增主键)、student_id、course_id、select_time、status。status 取值 0 表示已退选,1 表示已选上,这样保留历史记录,学生可以查看自己选过又退掉的课。
- score(成绩表):id、student_id、course_id、daily_score、exam_score、total_score、create_time、update_time。total_score 由 daily_score 和 exam_score 按比例计算得出,不手工录入。
2.2 为什么要有冗余字段和唯一索引
先说冗余字段。selected_count 存的就是课程表里的一个数字,理论上它不算“数据”,但它能让你的查询少一次子查询。比如展示课程列表时,前端需要显示“已选/容量”,如果走实时 count,一万个学生同时刷列表,数据库要承受一万次关联查询。冗余了 selected_count 后,直接读取字段就行,性能压力小得多。当然,冗余也意味着更新逻辑要谨慎,每次选课成功、退选成功,都必须同步更新这个字段。我把同步更新放在同一个事务里,保证一致性。
再说唯一索引。这是防止“同一学生重复选同一门课”的杀手锏。虽然前端按钮可以控制“选过了就禁用”,但只要有人用 Postman 直接调接口,前端拦截就全部失效。唯一索引建在 (student_id, course_id) 联合上,数据库层面直接拒绝重复行,这是最后一道也是最强的一道防线。我见过不建索引、靠后端代码 if 判断的案例,并发请求一多就漏,查出来一堆作弊数据,绩效全扣光。
2.3 成绩表的设计细节:为什么不直接把分数放进选课记录表
一开始我也觉得选课记录表和成绩表可以合并,但后来发现不行。选课记录是一个动作,成绩是一个结果,它们的生命周期不同。学期初学生选课,选课记录生成;学期末老师录入成绩,成绩记录生成。中间隔了好几个月,如果学生中途退课,选课记录会标记为 status=0,但成绩记录不应该被删除或改变。分表之后,退课只影响选课表,成绩表里保留的是“曾经选过并参与考核”的历史数据,这在统计重修、学分绩点时特别重要。
另外,成绩表里我故意加了 create_time 和 update_time,就是为了操作留痕。老师录入分数是一次写入,之后每次修改都会刷新 update_time。管理员查异常数据时,看到 update_time 和录入日志就能还原操作过程,答辩时老师问“你怎么保证数据安全”时也能有东西讲。
3. 后端核心接口设计与关键逻辑
3.1 路由规划与接口文档风格
接口设计我遵循 RESTful 风格,但不过度追求“纯洁的 REST”,而是以好用为先。每个控制器对应一组资源操作,方法名直接表达动作。以下是我实际的接口列表,按模块分组:
| 模块 | 方法 | 路径 | 说明 |
|---|---|---|---|
| 认证 | POST | /api/login | 登录并返回 token |
| 认证 | POST | /api/logout | 退出登录 |
| 课程 | GET | /api/courses | 分页获取课程列表,支持按名称搜索 |
| 选课 | POST | /api/enroll | 学生选课 |
| 选课 | POST | /api/drop | 学生退选 |
| 选课 | GET | /api/my-courses | 我的已选课程 |
| 成绩 | POST | /api/score | 教师录入/修改成绩 |
| 成绩 | GET | /api/score/student/{id} | 查询学生成绩 |
| 统计 | GET | /api/statistics/course/{id} | 课程选课人数与成绩分布 |
前端和后端约定统一返回格式:{ "code": 0, "msg": "success", "data": {} }。code 为 0 时表示成功,非 0 表示有业务异常,前端 axios 拦截器统一处理,不需要每个页面重复写错误提示。
3.2 登录认证与 Token 机制
登录流程我用 ThinkPHP 的中间件实现 JWT 认证。具体分三步:
- 用户提交账号密码,后端校验身份,成功后用
firebase/php-jwt生成 token,token 里只放 user_id、role、expire 三个字段,不塞多余信息。 - 前端拿到 token 后存到 localStorage,每次 axios 请求通过请求拦截器,把 token 放进
Authorization: Bearer <token>请求头。 - 后端写一个
AuthMiddleware,对所有/api下的请求做拦截(login 接口除外),解析 token,如果 token 过期或非法就直接返回 401,前端收到 401 后跳回登录页。
这里有个大坑:很多课设把角色判断写在控制器里,每个方法开头都来一段if ($_SESSION['role'] != 'admin'),又丑又容易漏。我的做法是给路由分组加中间件,比如Route::group('teacher', function(){...})->middleware(AuthMiddleware::class, ['role' => 'teacher']),角色校验在中间件里统一处理,控制器里干干净净只写业务。
3.3 选课逻辑:防超选、防重复、防冲突
选课接口是全部接口里最值得展开的。它要同时解决三个问题:
- 重复选课:联合唯一索引兜底(前面讲过了)
- 超容量选课:比如课程能容纳 60 人,当第 60 个人选完后,第 61 个人必须失败
- 时间冲突:同一时间不能选两门课
超容量问题用事务解决。伪代码如下:
public function enroll(Request $request) { $studentId = $request->user_id; $courseId = $request->course_id; Db::startTrans(); try { // 锁定课程行,防止并发下同时读到相同的 selected_count $course = Course::lock(true)->find($courseId); if ($course->selected_count >= $course->max_student) { Db::rollback(); return error('课程已满'); } $exists = StudentCourse::where('student_id', $studentId) ->where('course_id', $courseId) ->where('status', 1) ->find(); if ($exists) { Db::rollback(); return error('请勿重复选课'); } // 插入选课记录 StudentCourse::create([ 'student_id' => $studentId, 'course_id' => $courseId, 'status' => 1 ]); // 更新已选人数 $course->selected_count++; $course->save(); Db::commit(); return success(); } catch (\Exception $e) { Db::rollback(); return error('选课失败,请重试'); } }注意Course::lock(true)->find($courseId)这行。lock(true)在 ThinkPHP 里对应 SQL 的FOR UPDATE,意思是“我读取这条记录时,把这一行锁住,别人暂时不能改”。如果不加锁,两个请求同时读到了 selected_count=59,都得 60,然后各自插一条,结果变成 61,课程超员。加了锁以后,第二个请求会阻塞等待,等第一个事务提交后再读,此时 selected_count 已经是 60,直接判定“课程已满”。
时间冲突的判断放在模型里做,检查同一学生已选课程中是否存在与当前课程时间重叠的记录。课程时间的存储我建议用两个字段:week_day(星期几)和start_section(第几节开始),加一个end_section(第几节结束)。检查冲突就是查:
$conflict = StudentCourse::alias('sc') ->join('course c', 'sc.course_id = c.course_id') ->where('sc.student_id', $studentId) ->where('sc.status', 1) ->where('c.week_day', $course->week_day) ->where(function($query) use ($course) { $query->whereBetween('c.start_section', [$course->start_section, $course->end_section]) ->whereOr(function($q) use ($course) { $q->whereBetween('c.end_section', [$course->start_section, $course->end_section]); }); }) ->find();这个判断覆盖了“新课程开始时间落在已选课程区间内”和“新课程结束时间落在已选课程区间内”两种情况,虽然算不上完美,但已经能挡住绝大多数课表冲突。
3.4 成绩模块:权限边界与计算规则
成绩模块的逻辑不复杂,难在权限控制。教师只能给自己的课程录成绩,这是硬规矩。我在成绩录入接口里一定要校验:当前登录用户的 teacher_id 是不是这门课程的 teacher_id。很多系统因为漏了这一步,学生改个接口参数就能给自己打满分,答辩现场直接社会性死亡。
成绩计算规则通常是“平时成绩 40% + 期末成绩 60%”,但这只是个默认值。我建议在系统配置表里放一个权重配置项,方便调整。字段名就叫daily_weight和exam_weight,后端从配置读取,不硬编码在代码里。这样修改规则时不用改代码、不用重启服务,只要改数据库记录就行。
成绩统计接口我用 MySQL 的聚合函数实现。先按课程分组统计平均分、最高分、最低分、及格率,然后返回给前端展示。做到这一步,系统就不是单纯“录成绩的记事本”了,而是真的是一个“能分析成绩的管理系统”,答辩时可以拿出来做亮点讲。
4. 前端页面实现与交互细节
4.1 Vue 项目搭建与路由设计
前端我用 Vue CLI 创建项目。创建后第一步装依赖:vue-router、axios、element-ui(或 element-plus,看 Vue 版本定)、echarts(用来画统计图表)。生产环境打包配置我习惯把publicPath设为相对路径,即'./',这样打包后的 dist 目录可以随便放到任意子目录下,不用非得部署在域名根路径,Nginx 配置时也省心。
路由设计上,我用懒加载方式组织页面组件。核心路由表如下:
const routes = [ { path: '/login', component: () => import('@/views/Login.vue') }, { path: '/', component: () => import('@/layout/Layout.vue'), redirect: '/dashboard', children: [ { path: 'dashboard', component: () => import('@/views/Dashboard.vue'), meta: { title: '首页' } }, { path: 'course-list', component: () => import('@/views/student/CourseList.vue'), meta: { title: '选课大厅' } }, { path: 'my-courses', component: () => import('@/views/student/MyCourses.vue'), meta: { title: '我的课表' } }, { path: 'score-query', component: () => import('@/views/student/ScoreQuery.vue'), meta: { title: '成绩查询' } }, { path: 'teacher-score', component: () => import('@/views/teacher/TeacherScore.vue'), meta: { title: '成绩录入' } }, { path: 'admin-courses', component: () => import('@/views/admin/CourseManage.vue'), meta: { title: '课程管理' } }, { path: 'admin-students', component: () => import('@/views/admin/StudentManage.vue'), meta: { title: '学生管理' } }, ] } ]每个页面通过meta.title动态修改浏览器标签页标题,用路由守卫统一判断登录状态。
4.2 前端路由守卫与登录状态管理
路由守卫是前端防越权访问的关键。我写了全局前置守卫router.beforeEach,逻辑是:判断目标路由是否存在 token,不存在就跳登录页;存在就根据用户角色判断是否有权访问该路由。角色存在 Vuex(或 Pinia)里,登录成功后拉取用户信息并赋给 store。部分敏感路由(如管理员后台)加meta.roles字段,只允许指定角色进入。
这里我踩过坑:最初我把角色校验写在路由守卫里,但刷新页面会把 store 里存的数据清空,导致明明登录了却被弹回登录页。解决办法是刷新前把用户信息存到 localStorage,刷新后从 localStorage 恢复 store 数据。另外,axios 响应拦截器也要在拿到 401 时统一跳登录页,防止 token 过期后页面一直白屏。
4.3 关键页面:选课大厅与我的课表
选课大厅是整个系统的门面,也是交互最频繁的页面。我用 Element UI 的 el-table 展示课程列表,每一行是一个课程,行尾是一个“选课”按钮。按钮有两个状态:如果当前课程已选,按钮变为“退选”;如果已满,按钮禁用。这个状态绑定到row.isSelected和row.selected_count >= row.max_student两个字段,每次选课或退课成功后,手动更新当前行的数据,而不必重新请求整个列表,体验流畅。
我的课表页面我用 el-table 的“span-method”合并单元格,把周次和节次做成课表样式。实际操作中,我更推荐用一个自定义 div 网格渲染课表,虽然多写几十行代码,但视觉效果比表格合并灵活得多。前端组件传值这块,我用 props 从父组件向子组件传课程对象,子组件通过$emit通知父组件刷新数据,简单直接,不引入额外的复杂状态管理。
4.4 表格导出 Excel 的实操方案
热搜词里高频出现“vue 多个表格导出一个 excel”,说明大家确实都卡在这。我的方案分两种:
- 前端导出:用
xlsx库,XLSX.utils.json_to_sheet(data)把 JSON 数组转成 sheet,再XLSX.writeFile(wb, '课程表.xlsx')导出。适合小数据量(几百行),实现简单。 - 后端导出:PHP 用
PhpSpreadsheet生成 Excel 文件,前端通过接口下载二进制流,用blob方式处理。适合大数据量或需要服务端权限控制的情况。
我记得最开始时我用纯前端导出成绩表,结果学生多了以后浏览器直接卡死,后来改成后端导出方案,几十兆的文件也不在话下。前端下载逻辑贴个核心片段:
instance.post('/api/export/score', params, { responseType: 'blob' }) .then(res => { const blob = new Blob([res.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) const link = document.createElement('a') link.href = URL.createObjectURL(blob) link.download = `成绩表_${Date.now()}.xlsx` link.click() URL.revokeObjectURL(link.href) })注意responseType: 'blob'必须设置,否则返回的是乱码文本。URL.revokeObjectURL要调用,否则内存泄漏。
5. 部署环境配置、常见问题与避坑指南
5.1 本地环境:PHP 版本与 ThinkPHP 版本匹配
先说 PHP 环境。ThinkPHP 6 要求 PHP >= 7.2.5,推荐 7.4 或 8.0 以上。如果你用的是集成环境(phpStudy、小皮面板等),选 PHP 7.4 版本兼容性最好。不要用 PHP 5.x 跑 TP6,否则一堆语法错误。如果你手头有老项目是 ThinkPHP 3.2 的代码,直接放弃,TP3 不兼容 PHP 8,装起来全是白屏报错,没必要跟它死磕。
前端环境需要 Node.js 14 以上、npm 或 yarn。装依赖时如果报ignored build scripts: cpu-features@... esbuild@...这类警告,基本不用管,那是 npm 对新机器跳过可选依赖的提示,不影响最终打包。但如果npm run serve报端口被占用,就改端口,或者把占用进程关掉。
5.2 Nginx 部署 Vue 项目:刷新 404 问题
前端打包后生成的 dist 目录丢到服务器的/usr/share/nginx/html,配置 server 块时最关键的坑是:Vue Router 的 history 模式刷新会 404。因为刷新时浏览器直接向服务器请求/course-list,但服务器上并不存在这个文件,Nginx 就会返回 404。解决办法是用try_files把不存在的路径全部回退到 index.html:
location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; }try_files $uri $uri/ /index.html的意思是:先尝试按请求路径找文件,找不到就找目录,再找不到就回退到 index.html,由前端路由接管页面渲染。
5.3 跨域问题与反向代理配置
前后端分离项目一定会遇到跨域。我最推荐的方式是用 Nginx 反向代理,后端接口统一走/api/前缀,Nginx 把/api/开头的请求转发到 PHP 服务。这样前端代码里的请求地址不用写死 IP 和端口,统一写相对路径即可:
location /api/ { proxy_pass http://127.0.0.1:8000/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }注意proxy_pass http://127.0.0.1:8000/;结尾的斜杠非常关键。带斜杠表示“把 /api/ 去掉后再转发”,比如请求/api/login转发到后端实际是/login。如果你后端路由本身也带/api前缀,那 proxy_pass 结尾就不要加斜杠。很多同学卡在跨域半天,最后发现是斜杠问题,白白浪费两小时。
5.4 常见问题速查表
以下是我在实际开发和给同学调项目时碰到频率最高的几个问题,整理成表格方便你对照排查:
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 前端登录接口报 404 | 路由没注册或 Nginx proxy_pass 路径没配对 | 检查后端 route.php 是否有对应路由,curl 直接测试后端接口可通不通 |
| 所有请求提示 CORS 错误 | 后端没有设置跨域响应头 | ThinkPHP 写一个跨域中间件统一添加 Header,或用 Nginx add_header |
| 刷新页面就 404 | Vue Router history 模式没配 try_files | 修改 Nginx location 配置,加上 try_files |
| 选课并发人数超过容量 | 缺少数据库锁或唯一索引 | 事务加lock(true),联合唯一索引兜底 |
| token 明明有效却提示过期 | 服务器时间与本地时间差太大 | 校时,或 JWT 校验时放宽 leeway 时间容差 |
| 表格导出文件全是乱码 | 没设置 responseType 为 blob | axios 请求加responseType: 'blob' |
5.5 开发提效心得:先做好 Mock 数据
最后分享一个加速开发的小技巧。前后端分工后,后端的接口还没写完时,前端不要干等着。我习惯在前端项目里用mockjs或直接在 axios 层拦截,按接口文档先模拟假数据。这样前端页面开发完全不受后端进度影响,等后端接口真正可用时,只需要把 mock 关掉,改一行 baseURL 就能联调。我在做这个选课系统时,前端页面用了三天就全部写完了,而后端接口因为要处理并发和权限逻辑多花了两天,但因为并行开发,总工期反而被压缩了将近一半。
另外,数据库里一定要写一批逼真的模拟数据,比如 200 个学生、30 门课程、每个学生选 5 门课、老师录入完整成绩。答辩演示时,空表演示给老师的印象会打五折,密密麻麻的真实数据一铺开,系统立体感立刻就有了。我写了一个独立的seed.php脚本,一次性生成全部模拟数据,每学期答辩前跑一遍,省心省力。