这几年用Python做课设、毕设的人越来越多,其中“python基于vue的大学生自助选课系统”算是出现频率极高的一类全栈需求。后端用Django或Flask,前端用Vue,开发工具选PyCharm,几乎成了标准组合。今天这篇内容,不打算讲那种“只跑通一个页面”的演示项目,而是把一个真正能用的自助选课系统从需求拆解、环境搭建、后端接口、前端页面到联调部署的完整过程捋一遍。选课系统的核心其实不复杂:学生登录、看课表、选课、退课,系统自动检查时间冲突和人数限制。但越简单的业务,越容易在并发抢课、跨域请求、前后端字段对不上这些细节上翻车。我会把实际开发中踩过的坑一并写出来,正在做这个方向的人可以直接照着抄。
1. 整体设计与技术选型:先拆清楚需求再动手写代码
1.1 核心需求拆解:课程、选课记录和冲突检测
任何项目上手第一步都不是新建文件夹,而是把业务规则一条条列清楚。大学生自助选课系统里,角色大概分两类:学生和管理员。学生看课程列表、按自己时间选课、退课、查看已选课程;管理员维护课程信息、设置课程容量、查看选课统计。这是最基础的中小型MIS系统模型。
把业务规则翻译成技术规则后,真正需要注意的约束条件是:
- 同一学生不能选择上课时间重叠的两门课程
- 每门课程有选课人数上限,满了就不能再选
- 学生可以退课,退课后释放名额
- 系统需要记录选课时间,方便后续统计
这些规则看起来简单,但落到数据库设计和接口逻辑时,每一步都要对应好。比如时间冲突检查,课程表里不能只存一个“上课时间”字符串,而要用星期几加节课/时间段的方式存储,或者至少存两个字段:开始节次和结束节次。否则后续做冲突判断会非常痛苦。我在实际项目里还见过有人把上课时间存成“周一 3-4节 5-6节”这种带中文的文本,结果接口里没法做任何比较,最后只能改表结构重来。所以第一步要把数据字典定清楚。
1.2 为什么选Python + Vue,Django和Flask如何取舍
选课系统这种业务,用Python写后端效率很高。Python生态里最常用的两个Web框架就是Django和Flask,很多人纠结怎么选。我的判断标准很简单:如果你需要快速看到完整后台管理功能,并且希望ORM、迁移、表单、认证这些现成的能力比较齐,首选Django。如果你只想做轻量级API服务,喜欢自己搭结构,想更自由地控制代码组织,那Flask更合适。
| 对比项 | Django | Flask |
|---|---|---|
| 内置ORM | 有,功能强大 | 无,需要搭配SQLAlchemy |
| 自带后台Admin | 有,非常方便 | 无,需自己开发或扩展 |
| 项目结构 | 固定,models/views/urls分层明确 | 灵活,自己决定怎么组织 |
| 上手难度 | 中等,概念多但统一 | 较低,核心就是路由和视图 |
| 适合场景 | 快速开发、后台管理多、模板渲染 | 轻量API、微服务、高度定制 |
有一段时间FastAPI也很火,性能好、自带接口文档,适合纯API场景,但选课系统里Django的Admin后台确实能省下大量管理端开发时间。甚至管理员课程维护这个模块,用Django Admin改几行配置就够了。Flask的话就得自己写一套管理页面或者单独做个前端管理界面。
我这次主讲的路径是Django作为主力后端,因为它的ORM和事务管理在选课这种高并发场景下更顺手。但Flask版本涉及到的关键接口逻辑我也会一并写上,原理是通用的。前端选Vue 3,配上Vue Router做页面跳转、Pinia做状态管理、Axios发请求。用Vue写这种交互型页面比传统的模板渲染体验好太多了,组件化开发也让课程卡片、选课列表这些部分可以被复用。
1.3 数据表设计和项目目录结构规划
选课系统的数据表核心就是三张:学生(Student)、课程(Course)、选课记录(Enrollment)。学生表主要字段有学号、姓名、所属专业;课程表字段要包含课程名称、课程编号、学分、上课时间、上课地点、授课教师、课程容量、已选人数;选课记录表是关联表,维护student_id、course_id、选课时间,并且要设置联合唯一索引,防止同一学生重复选同一门课。
课程表里的“上课时间”字段建议不要存单字段文本,而是拆成day_of_week(星期几)和start_section、end_section(第几节到第几节),这样冲突检查就是纯数值比较。比如“周一第3-4节”,对应day_of_week=1、start_section=3、end_section=4。这个设计是整个败笔的高发点,一定要提前想好。
项目目录结构,我习惯拆成backend和frontend两个独立目录。后端是Django项目,内部有apps/courses、apps/users这些模块;前端是Vue工程,下面有src/views、src/components、src/api。前后端分离开发时,两个项目各跑各的端口,联调时Django跑在8000,Vue跑在5173,代理转发解决跨域。后面章节会详细说这个。
2. 环境准备:PyCharm里配置Python和Vue三个核心点
2.1 PyCharm创建项目与Python虚拟环境
写Python项目,PyCharm是我长期在用的IDE。选课系统这个项目建议用PyCharm Professional版本,因为Django和Vue的支持都更完整。启动后先创建新项目,选择Django模板或者纯Python项目都可以。如果用Django模板,PyCharm会自动生成manage.py、settings.py这些文件;如果用纯Python项目,后面再自己执行django-admin startproject也没问题。
虚拟环境一定要用起来。很多初学者直接使用全局Python环境,装了一堆包后版本冲突到怀疑人生。PyCharm里新建项目时会默认帮你创建一个虚拟环境,不过是用venv还是conda可以看你的习惯。我一般用venv,简单干净。创建完环境后,在PyCharm的Terminal执行:
python -m venv venv venv\Scripts\activate # Windows source venv/bin/activate # Mac/Linux pip install django flask-cors # 按需装这里顺带回答一个高频问题:PyCharm里装第三方库失败怎么办,比如有人想装pandas、装htmltestrunner,直接点右下角的包管理器装不上时,就去Settings -> Project -> Python Interpreter看解释器路径是否选对了,以及pip是否更新。命令行里执行python -m pip install --upgrade pip基本能解决大多数包装不上的问题。
2.2 前端Vue环境与依赖安装
Vue开发环境的核心是Node.js。去Node官网下载LTS版本安装,装完自带npm。然后校验一下版本:
node -v npm -v用Vite创建Vue 3项目是最快的:
npm create vite@latest frontend -- --template vue cd frontend npm install npm install vue-router@4 pinia axios npm run dev为什么不用Vue CLI?因为Vue官方已经转向Vite了,Vite启动速度快、配置也直观,尤其适合前后端分离项目。有人搜索“vue安装及环境配置”“vue安装依赖”,大概率卡在网络或者Node版本上。如果npm安装依赖非常慢,可以切换淘宝镜像源:
npm config set registry https://registry.npmmirror.com依赖装完之后,工程结构里就能看到src/components和src/views,后面页面组件就往这里放。
2.3 初始化Django应用并确认依赖清单
后端初始化不要着急写代码,先把依赖和项目结构搭好。Django项目执行:
django-admin startproject config . python manage.py startapp users python manage.py startapp courses这里有个小坑:PyCharm的Django模板生成的目录不一定符合你的预期,最好手动确认settings.py里是否把新增的app都注册到了INSTALLED_APPS。users和courses都要注册。然后装跨域支持的库,Django处理跨域很方便:
pip install django-cors-headers在settings.py的INSTALLED_APPS加上corsheaders,中间件加上corsheaders.middleware.CorsMiddleware,再设置允许跨域来源:
CORS_ALLOWED_ORIGINS = [ "http://localhost:5173", ]如果你用的是Flask后端,对应的处理方式是安装flask-cors,然后CORS(app)一行就能解决,但因为它的第三方包比较轻,需要自己把跨域配置做细。
这一段准备工作的精髓是:环境一定一次弄干净,别等到联调时再回头补依赖,那是白白浪费时间。
3. 后端API实现:从课程表编写到抢课的冲突检测
3.1 Django模型定义与数据迁移
后端核心是模型层,也就是数据库存什么字段、表之间怎么关联。以Django为例,我把courses/models.py里的Course模型简化成下面这样:
from django.db import models class Course(models.Model): name = models.CharField(max_length=100, verbose_name="课程名称") course_code = models.CharField(max_length=20, unique=True, verbose_name="课程编号") credit = models.FloatField(default=2.0, verbose_name="学分") teacher = models.CharField(max_length=50, verbose_name="授课教师") day_of_week = models.IntegerField(choices=[(i, f"周{i}") for i in range(1, 8)], verbose_name="星期") start_section = models.IntegerField(verbose_name="开始节次") end_section = models.IntegerField(verbose_name="结束节次") capacity = models.IntegerField(default=30, verbose_name="容量") selected_count = models.IntegerField(default=0, verbose_name="已选人数") class Meta: db_table = "course" def __str__(self): return self.nameEnrollment模型:
class Enrollment(models.Model): student = models.ForeignKey(Student, on_delete=models.CASCADE, verbose_name="学生") course = models.ForeignKey(Course, on_delete=models.CASCADE, verbose_name="课程") enroll_time = models.DateTimeField(auto_now_add=True, verbose_name="选课时间") class Meta: db_table = "enrollment" unique_together = ("student", "course")selected_count字段是冗余字段,可以理解为“课程已选人数”,它必须和Enrollment的真实记录数保持一致。为什么要加这个字段?因为选课时如果每次都要count()一遍Enrollment表,在高并发下既慢又容易出现逻辑漏洞。冗余字段配合下面要讲的事务和锁,才能保证容量不超。
写完后执行:
python manage.py makemigrations python manage.py migrate这一步就是所谓“django执行查询-删除对象”的起点,所有增删改查都建立在迁移成功后干净的表结构上。
3.2 选课接口与时间冲突检测逻辑
选课接口是系统的灵魂。接口逻辑不只是“往Enrollment表里insert一条”,而是要顺序完成:校验课程存在、校验课程是否有余额、校验时间冲突、写入记录、更新已选人数。下面是我常用的Django视图写法:
from django.db import transaction from django.db.models import Q from rest_framework.decorators import api_view from rest_framework.response import Response @api_view(["POST"]) @transaction.atomic def enroll_course(request): student = request.user.student_profile course_id = request.data.get("course_id") course = Course.objects.select_for_update().get(id=course_id) # 容量检查 if course.selected_count >= course.capacity: return Response({"code": 1, "msg": "人数已满"}) # 时间冲突检查 conflict = Enrollment.objects.filter( student=student, course__day_of_week=course.day_of_week, course__start_section__lt=course.end_section, course__end_section__gt=course.start_section, ).exists() if conflict: return Response({"code": 1, "msg": "上课时间冲突"}) Enrollment.objects.create(student=student, course=course) course.selected_count += 1 course.save(update_fields=["selected_count"]) return Response({"code": 0, "msg": "选课成功"})时间冲突的判断条件看起来短,其实很关键。我判断重叠区间用的就是两个区间相交的数学规则:旧课开始 < 新课结束且新课开始 < 旧课结束。只要符合这个条件,两个时间段就一定有重叠。因为我已经把课程时间拆成了星期几和节次两个维度,day_of_week先过滤,然后用节次区间做重叠判断,非常高效。
这里还要注意一个细节:为什么选课创建和人数更新放在一个事务里?因为如果先创建Enrollment,再更新人数,中间发生异常时会出现记录和人数不一致。@transaction.atomic保证两步要么全成功要么全失败。
如果是Flask版本,一样可以用SQLAlchemy的session配合with db.session.begin():来包事务。核心不是框架,而是事务边界。
3.3 并发抢课时防止超选和重复选
选课系统最容易暴露问题的场景就是抢热门课。很多人同时点选课,如果不做任何并发控制,容量30的课程可能被选进去50人,数据库里也没有约束能拦住。这就是为什么我上面用了Course.objects.select_for_update()。
select_for_update()是数据库层面的行锁。事务A拿到课程行锁后,事务B在同一个时间段就必须等待,A提交后B才能继续。这样B读到selected_count时,A已经更新过了,容量检查自然能拦住超选。在MySQL的InnoDB引擎下,这个锁是真实有效的。
不过使用行锁要注意:事务里别做耗时的外部请求,比如调用其他接口、发邮件,否则把锁挂太久会拖垮并发。对于选课这个场景,锁住课程行的范围很小,性能基本可以忽略。从业务设计上还可以再加一道兜底:在数据库里用唯一索引student+course挡住重复选课。“唯一约束兜底 + 业务逻辑判断”是最稳妥的组合。
4. Vue前端页面开发:从路由配置到课程卡片展示
4.1 Vue Router与页面结构设计
前端页面结构我划分为三个主要视图:登录页、课程列表页、已选课程页。后台管理可以再加一个课程管理页。Vue Router把这些视图串起来:
import { createRouter, createWebHistory } from "vue-router"; const routes = [ { path: "/login", component: () => import("../views/Login.vue") }, { path: "/courses", component: () => import("../views/CourseList.vue") }, { path: "/mine", component: () => import("../views/MyCourses.vue") }, ]; const router = createRouter({ history: createWebHistory(), routes, }); export default router;懒加载就是用() => import()的方式让每个页面独立打包,首屏加载更快。如果只是课设,不懒加载也不是什么大问题,但用上这个习惯挺好。
4.2 组件拆分与插槽的实用场景
前端页面如果全写在一个文件里,几百行代码堆在一起,维护体验非常糟糕。我会把课程卡片抽成一个组件CourseCard.vue,然后在列表页用v-for渲染。
这里顺便说一个高频搜索词“vue插槽”的实际用途。卡片组件里有些区域是固定的,比如课程名、教师、时间;但卡片底部的操作按钮会因状态变化而不同,有时候显示“选课”,有时候显示“已选”,还有管理员的“编辑”。这种场景非常适合用slot插槽:
<template> <div class="course-card"> <h3>{{ course.name }}</h3> <p>{{ course.teacher }} · 周{{ course.day_of_week }} 第{{ course.start_section }}-{{ course.end_section }}节</p> <slot name="action"></slot> </div> </template>使用组件时:
<CourseCard v-for="course in courseList" :key="course.id" :course="course"> <template #action> <button @click="handleEnroll(course)">选课</button> </template> </CourseCard>这样页面结构清爽,复用性也高。如果课程有视频预览或者教学资源需要播放,前端组件里还可以放一个<video>标签,配合hls.js播放m3u8流——比如嵌入课堂回放或课程宣传片。虽然自助选课系统不一定需要这个功能,但作为扩展加分项很值得了解。
4.3 Axios请求封装与状态管理
选课页面交互的核心动作是异步请求。Axios直接在每个页面里写会很乱,我习惯把请求统一封装到src/api/request.js:
import axios from "axios"; 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; }); export default request;接口调用示例:
import request from "@/api/request"; export function enrollCourse(courseId) { return request.post("/enroll", { course_id: courseId }); }页面里拿到响应后统一处理,比如选课成功刷新列表,失败则用消息提示框把后端返回的msg展示给用户。
全局状态用Pinia来管理学生信息、选课列表这些跨页面共享的数据。比如登录后将用户信息扔进store,后续页面从store里读取,避免每个页面都请求一次用户接口。
5. 前后端联调与启动全流程:跨域、代理和接口验证
5.1 跨域问题与Vite代理配置
前后端分离开发时,一个跑5173端口,一个跑8000端口,浏览器直接发请求会被CORS策略拦住。虽然我在后端设置过CORS_ALLOWED_ORIGINS,但更推荐的方法是在前端开发服务器里做代理,让Vue的请求看起来像是同一个源发出的。
在Vite项目根目录打开vite.config.js:
export default { server: { proxy: { "/api": { target: "http://localhost:8000", changeOrigin: true, }, }, }, };这样前端请求/api/courses在开发环境下会被转发到http://localhost:8000/api/courses。代理的好处是前端代码里不需要写完整的带端口地址,后面生产部署切换环境也更方便。
5.2 构造测试数据与接口调试
联调前后最烦人的事情就是没有数据可调。我一般会写一个Django management command或者直接用Django Admin后台往数据库塞测试数据。Django Admin在这里的优势就体现了,注册Course模型:
from django.contrib import admin from .models import Course @admin.register(Course) class CourseAdmin(admin.ModelAdmin): list_display = ["name", "teacher", "day_of_week", "start_section", "end_section", "capacity", "selected_count"]开发阶段直接用Admin后台创建几门模拟课程,不同时间段的课程数据准备几组,测试冲突检测时就能看到效果。再用Postman直接调用后端接口验证:
POST http://localhost:8000/api/enroll/ Body: {"course_id": 1}看返回结果是否符合预期。也建议用Postman把退课接口、课程列表接口都调一遍,确认字段名称前后端一致。我见过很多联调问题,最后发现就是前端传了courseId,后端却期望course_id,这种低级错误一旦出现排查起来特别耗时间。
5.3 从PyCharm启动Django和Vue的完整流程
PyCharm里启动后端很简单:直接把项目里的manage.py配置成一个Django Server运行配置,端口设置为8000。启动后终端会显示Starting development server at http://127.0.0.1:8000/。
前端则需要在PyCharm的Terminal窗口里进入frontend目录,执行:
npm run dev然后浏览器访问http://localhost:5173,登录后就能看到前端的课程列表。这里有个体验优化:可以在PyCharm里新增一个npm运行配置指向dev脚本,这样点一下绿三角自动启动前端。两个服务都跑起来后,整个联调环境就通了。
6. 常见问题与排查技巧:把这几个典型的坑提前给你
6.1 环境安装类问题速查
| 现象 | 原因 | 解决办法 |
|---|---|---|
| pip install 超时或下载慢 | 默认官方源慢 | 换清华/阿里镜像源或用-i参数 |
| npm install 一直卡住 | npm官方源不稳定 | 配置registry.npmmirror.com |
| PyCharm无法识别Vue语法 | 未安装Vue.js插件 | Settings -> Plugins 搜索Vue.js安装 |
| python命令不是内部或外部命令 | Python未加入环境变量 | 安装时勾选Add Python to PATH |
| node -v成功但npm -v失败 | Node版本异常 | 重装Node LTS版本 |
有个细节值得单独提醒:如果你在PyCharm里用虚拟环境,新开终端一定要确保虚拟环境已激活。否则明明pip装过的包,运行项目时找不到,多半就是解释器没选对。PyCharm右下角可以切换Python解释器,这个是排查环境问题的第一入口。
6.2 业务逻辑类问题速查
- 选课人数容量不同步:手动通过数据库改过
selected_count,或者直接在表里插了Enrollment记录没走接口。解决办法是检查接口里是否用事务包住创建记录和更新数量两步,并定期用SQL统计核对。 - 时间冲突检测不生效:检查课程表里是否把上课时间存成了中文文本,或者
day_of_week的值没有对应上。还有一个小坑:如果只比较start_section和end_section但漏掉了“星期几”条件,就会出现周一和周三的课程互相冲突的假阳性。 - Django查询-删除对象时报错:退课场景中删除Enrollment记录时,外键关联的学生或课程记录如果已经被删除,Django的
on_delete=CASCADE会导致联级删除。如果不想物理删除,可以在表中加一个is_active字段做软删除,这样日志和统计都不受影响。
6.3 部署与交付注意事项
如果是要交课程设计或准备答辩,强烈建议把项目启动过程写成 README,环境依赖锁文件固定下来。后端用requirements.txt,前端用package.json。另外不要硬要把前后端部署到同一个服务上。生产环境可以把npm run build生成的dist目录交给Djangostaticfiles托管,但更清晰的做法是后端API部署到一个端口,前端静态文件部署到Nginx并配置反向代理。选课这种小型系统一个人维护起来,反向代理方案最省心。
最后说点个人体会:这类型全栈项目,最大的价值不是炫技,而是在小规模场景里把数据库设计、事务边界、异步请求和组件化开发这几个基本功全部过一遍。我实际写完这个选课系统之后,最大的收获是理解了“越简单的业务越考验细节”这句话。尤其是那些在并发选课中暴露出来的问题,几乎都是因为一开始没有想清数据表之间的约束关系。建议你在开发前先花半小时把表和接口捋清楚,写代码也就一两天的功夫。如果中途卡住,优先查后端日志和浏览器Network面板,那种到处瞎试代码的效率是最低的。