做网上考试系统,很多第一次上手的人会觉得“不就是一个答题网页加个数据库嘛”。真正把功能做完整才发现,光“试卷怎么来、考完怎么判、成绩怎么算、怎么防止有人卡点交卷卡出问题”这四件事,就能把人折腾到凌晨。我这次用 Python + Vue 从零写了一套完整的网上考试系统,开发环境是 PyCharm,后端主干用 Django,另外用 Flask 单独做了一个辅助服务。系统覆盖学生、教师、管理员三类角色,包含题库管理、自动组卷、在线考试、自动判分、人工阅卷和成绩导出。写这篇文章的目的,是把整个设计思路和实现细节完整复盘一遍,给正在做课程设计、毕业设计,或者打算练手前后端分离项目的朋友做个参考——你能抄的代码、能避的坑,我都尽量放在这里了。
这套系统说白了要回答三个问题:谁来考、考什么、怎么算分。围绕这三个问题,后端用 Django 做主体业务,因为它自带 ORM、Admin 和认证体系,写这类管理密集型的应用最顺手;前端用 Vue 来承载考试界面,倒计时、答题卡、题目跳转这些交互用组件化开发非常自然;Flask 则被我用在一个听起来有点“奢侈”的位置——单独做成绩导出和自动收卷的辅助服务。很多同学会问,Django 和 Flask 为什么要同时出现?单独用哪个不行?别急,这个争议我放在后面专门讲,先把整体框架搭起来再说。
1. 项目整体设计与技术选型思路
1.1 网上考试系统要解决的核心问题
传统纸质考试的问题大家都体会过:出卷靠手挑题,批量出几套卷子就累得够呛;阅卷靠人改客观题,明明有标准答案还是容易看花眼;成绩登记靠 Excel 来回传,弄丢一列数据全班成绩就乱了。网上考试系统要解决的,就是把“出题—组卷—考试—判分—统计”这条链路全部数字化。
我的系统按角色划分功能边界。学生端只做两件事:参加考试和查看成绩。教师端负责题库维护、试卷配置、人工阅卷和成绩导出。管理员端管用户和基础数据,比如学科分类、考试场次设置。这里有一个比较容易忽略的点:考试系统不是把试卷堆在网页上就完事了,它天然带有“权限”属性——谁能看到这张卷子、谁能在什么时间段内进入考试、谁有权限批量导出成绩,这些都是权限控制要覆盖的范围。所以我会在用户模型里加角色字段,再配合 Django 自带的 Group/Permission 做了一套轻量级的 RBAC 权限控制,避免所有接口裸奔。
除了权限,核心问题还有三个:第一,题库如何组织,题目类型、难度、学科这些维度怎么存;第二,试卷如何生成,既要支持教师手动选题,也要支持按规则随机从题库抽题;第三,判分如何做,客观题自动判、主观题人工判,最后怎么把两类分数合并成总分。这三个问题对应三个数据模块,后面第 3 部分会逐个拆解。
1.2 技术选型:Python + Vue 为什么能打
Python 做后端,最大的优势是上手快、生态全。Django 自带 Admin 后台、ORM、表单处理和用户认证,对于课程设计和毕业设计这种体量的项目来说,几乎不用额外引第三方库就能跑通主流程。而且答辩时老师随口问“你怎么处理 SQL 注入”“密码存的是明文吗”,你可以理直气壮地回答:Django ORM 做了参数化查询,密码用 PBKDF2 哈希存储,这些框架层已经处理好了。这就能省下大量吹嘘成本,把精力放在业务逻辑上。
Vue 的优势在前端交互。考试页面不是普通的信息展示页面,它有倒计时刷新、答题卡状态切换、题目跳转定位、多选题勾选联动,这些东西如果用 jQuery 手写 DOM 操作,代码会散成一片;用 Vue 的数据驱动和组件化思维,每个功能都能拆成独立模块,状态集中在 data 里,逻辑清晰很多。Vue 还自带路由和状态管理生态,配合 Axios 做前端请求层,前后端分离的架构很标准。
我选择在 PyCharm 里同时开发前后端。PyCharm 专业版内置了 Vue 插件,能识别 .vue 文件并高亮模板、脚本和样式;社区版也能用,只是没有前端插件,但配合 VSCode 写 Vue 也完全没问题。实际开发时我在 PyCharm 里维护 Python 虚拟环境和 Django 配置,前端代码用 PyCharm 或 VSCode 混着写都行,反正最终通过 HTTP 接口通信,IDE 不互相影响。
1.3 Django 与 Flask 共存的分工逻辑
按市面上的普遍做法,Django 和 Flask 一般只选一个。我在这个项目里为什么两个都用?不是炫技,而是有明确的场景需求。Django 负责主体业务系统:登录认证、题库管理、考试排期、答题记录、自动判分,这些功能依赖 Djang 的数据模型和事务机制,硬塞进 Flask 会让代码结构变得很拧巴。 Flask 则单独跑在另一个端口上,只做两件轻量的事情:一是成绩报表导出,用 openpyxl 把成绩数据写进 Excel 文件再返回给前端下载;二是定时扫描考试场次,发现考试结束时间已到还没有提交的考生,就自动帮他们强制交卷并触发判分逻辑。
这两个功能之所以不并进 Django,原因是它们有独立的节奏和故障域。导出 Excel 一旦遇到大数据量会明显耗时,如果放在 Django 进程里同步执行,可能会拖慢整个考试服务的响应;定时任务如果由 Django 的进程来跑,每次重启工程还要额外考虑调度器会不会重复启动的问题。拆成 Flask 微进程之后,业务服务和辅助服务可以独立重启、独立部署,互不干扰,这也符合微服务里“按业务边界拆服务”的思路。答辩时老师问你为什么要拆,你可以把这段话组织一下,比“我就是想多用点框架”强得多。
2. 开发环境与工程初始化
2.1 PyCharm 搭建 Python 后端环境
打开 PyCharm 官网下载安装包,选择 Community 版就够了,社区版免费且支持 Python 后端开发,对课程设计来说完全够用。安装完成后新建项目,关键一步是配置解释器:建议在项目根目录创建虚拟环境,命令是python -m venv venv,然后在 PyCharm 的 Settings → Project → Python Interpreter 里选中这个虚拟环境。很多人图省事直接用系统全局 Python,项目一多就出现“昨天还能跑,今天 pip 装了个新包把依赖搞坏了”的惨剧,虚拟环境能把这层风险隔离开。
进入虚拟环境后安装依赖,我会把核心包列一个清单:
| 包名 | 版本建议 | 用途 |
|---|---|---|
| Django | 4.x | 主后端框架 |
| djangorestframework | 3.14+ | 提供 API 开发支持(可不装,但推荐) |
| django-cors-headers | 4.x | 解决前后端跨域 |
| Flask | 3.x | 辅助服务 |
| openpyxl | 3.1+ | 成绩导出 Excel |
| PyMySQL | 1.x | MySQL 驱动 |
| APScheduler | 3.10+ | 定时扫描考试状态 |
安装完成后建议统一写入 requirements.txt 固化版本,方便换个电脑一键恢复环境。由于项目里有多个单独运行的 Web 服务(Django 和 Flask),我会保持一个项目目录下分别建exam_backend/和flask_export/两个子工程,共用同一个虚拟环境,这样依赖管理不会乱。
2.2 Django 项目与第一个 App
后端用命令初始化工程:
django-admin startproject exam_backend cd exam_backend python manage.py startapp users python manage.py startapp questions python manage.py startapp exams按业务拆 App,是 Django 项目里最容易踩坑也最容易受益的地方。很多新手喜欢把 model 全堆在一个 app 里,几十张表混在一堆文件里,后续改一个字段要找半天。我的习惯是一个业务域一个 app:users 管用户和权限,questions 管题目和学科,exams 管试卷、考试记录和答题明细。每个 app 的 models.py 都保持短小,表关系通过 ForeignKey 串联,阅读起来非常顺。
初始化后要调整 settings.py,这几项是必修课:
INSTALLED_APPS = [ # ... 'corsheaders', 'rest_framework', 'users', 'questions', 'exams', ] MIDDLEWARE = [ 'corsheaders.middleware.CorsMiddleware', # 尽量放前面 # ... ] LANGUAGE_CODE = 'zh-hans' TIME_ZONE = 'Asia/Shanghai' USE_TZ = True CORS_ALLOWED_ORIGINS = [ 'http://127.0.0.1:8080', ]注意USE_TZ = True意味着数据库里存的是 UTC 时间,展示时再转本地时区。考试系统里“开始时间、结束时间、交卷时间”都是关键字段,处理不好会出现学生 9 点考试系统显示 17 点的诡异情况,这一点我在排查部分还会专门强调。建好工程后别忘了python manage.py createsuperuser创建一个管理员账号,Django Admin 可以用来临时维护题库数据,非常方便。
2.3 Vue 前端项目初始化与路由骨架
前端我用 Vue 3 + Vite 创建项目。命令如下:
npm create vue@latest exam_frontend -- --router cd exam_frontend npm install npm install axios element-plus有的同学会遇到 npm 安装依赖时网络慢或者版本冲突,这时候可以用国内镜像源https://registry.npmmirror.com,把.npmrc里 registry 指过去,速度立竿见影。如果报 ERESOLVE 依赖树冲突错误,多半是 npm 7 以上对 peerDependencies 检查过严,可以加--legacy-peer-deps参数绕过。
前端目录我按页面角色组织:
src/views/ login/Login.vue student/ExamList.vue student/DoExam.vue student/ExamResult.vue teacher/QuestionManage.vue teacher/PaperConfig.vue teacher/GradeManage.vue admin/UserManage.vue src/router/index.js src/utils/request.js src/api/路由设计上,除登录页外所有页面都挂在 AppMain 布局下面,并设置路由守卫:未登录的用户一律重定向到 /login,学生角色访问教师页面时直接 401 拦截。守卫逻辑写在 router.beforeEach 里,每次跳转读取 localStorage 里的 token 和角色信息,实现轻量鉴权。
2.4 前后端联调代理配置
前后端分离开发时最烦的就是跨域。浏览器不允许前端页面直接请求不同端口的接口,这是安全策略,防止恶意网站随便窃取你登录其他网站的状态。开发阶段最简单的方案不是在后端挨个接口加 CORS 头,而是让 Vue 的开发服务器做一层代理,把接口转发到 Django。
我在项目根目录新建vue.config.js:
const { defineConfig } = require('@vue/cli-service') module.exports = defineConfig({ devServer: { port: 8080, proxy: { '/api': { target: 'http://127.0.0.1:8000', changeOrigin: true }, '/export': { target: 'http://127.0.0.1:5000', changeOrigin: true } } } })这样前端请求/api/login时,Vite 开发服务器会把它转发到 Django 的 8000 端口;请求/export/grades时转发到 Flask 的 5000 端口。前端代码里写的所有接口都看起来像同源的,浏览器不会再报 CORS 错误。生产部署时这条路走不通,得靠 Nginx 把/api反向代理到 Django、把静态资源交给前端服务器,不过这属于部署话题,课程设计到联调跑通这步已经够用。
3. 数据库建模与核心模块实现
3.1 用户体系设计与权限控制
用户体系是整个系统的地基。我直接继承 Django 的 AbstractUser,在自带 username、password、is_superuser 基础上增加 role 字段和 real_name 字段:
# users/models.py from django.contrib.auth.models import AbstractUser from django.db import models class User(AbstractUser): ROLE_CHOICES = ( ('student', '学生'), ('teacher', '教师'), ('admin', '管理员'), ) role = models.CharField(max_length=10, choices=ROLE_CHOICES, default='student') real_name = models.CharField(max_length=50, blank=True) class Meta: db_table = 'sys_user'权限控制这块,很多人提到 RBAC(Role-Based Access Control,基于角色的权限控制)会觉得很高深,说白了就是“角色决定你能干什么”。我这里的角色字段就是 RBAC 的最简形态,再配合一个自定义装饰器控制接口访问:
# users/permissions.py from functools import wraps from django.http import JsonResponse def require_role(*roles): def decorator(view_func): @wraps(view_func) def wrapper(request, *args, **kwargs): if not request.user.is_authenticated: return JsonResponse({'msg': '未登录'}, status=401) if request.user.role not in roles and not request.user.is_superuser: return JsonResponse({'msg': '无权限'}, status=403) return view_func(request, *args, **kwargs) return wrapper return decorator这样做的好处是权限规则写在每个视图的入口处,一眼就能看出这个接口是给谁调的。比在业务代码里写 “if user.role == ...” 要整洁得多,也方便老师答辩时检查安全设计有没有遗漏。
3.2 题库、试卷、考试记录的三层模型
题目、试卷、考试记录是整个系统的三大核心表。我先放题目模型:
# questions/models.py class Question(models.Model): TYPE_CHOICES = ( ('single', '单选题'), ('multi', '多选题'), ('judge', '判断题'), ('essay', '主观题'), ) subject = models.ForeignKey(Subject, on_delete=models.CASCADE) q_type = models.CharField(max_length=10, choices=TYPE_CHOICES) content = models.TextField() options = models.JSONField(null=True, blank=True) answer = models.CharField(max_length=50) score = models.DecimalField(max_digits=4, decimal_places=1, default=2.0) difficulty = models.IntegerField(default=3)options字段我用 JSON 存储选择题选项,比如["A. Python", "B. Java", "C. C++", "D. Ruby"],这样不需要单独建一张选项表,查询时一次性读出;客观题答案只存一个字符串,单选题存 “A”,多选题存 “ABD”,判断题存 “T/F”。
接下来是试卷模型。这里有一个容易忽略的设计点:试卷里的题目必须“快照”下来,而不是外键关联到题库里的题目。原因是题库可能会被老师反复修改,试卷一旦发布,考试内容就应该是固定的,否则学生考到一半题目变了,你连申诉都没法处理。所以我建了 PaperItem 中间表,把题目内容和分数在组卷那一刻复制过来。
# exams/models.py class Paper(models.Model): title = models.CharField(max_length=100) subject = models.ForeignKey(Subject, on_delete=models.CASCADE) total_score = models.IntegerField(default=100) duration = models.IntegerField(help_text='考试时长(分钟)') start_time = models.DateTimeField() end_time = models.DateTimeField() published = models.BooleanField(default=False) class PaperItem(models.Model): paper = models.ForeignKey(Paper, related_name='items', on_delete=models.CASCADE) question = models.ForeignKey(Question, on_delete=models.CASCADE) score = models.DecimalField(max_digits=4, decimal_places=1) order_no = models.IntegerField(default=0)考试记录和答题明细则是考试过程的“流水账”:
class ExamRecord(models.Model): STATUS_CHOICES = ((0, '未开始'), (1, '考试中'), (2, '待阅卷'), (3, '已完成')) student = models.ForeignKey(User, on_delete=models.CASCADE) paper = models.ForeignKey(Paper, on_delete=models.CASCADE) start_at = models.DateTimeField(null=True) submit_at = models.DateTimeField(null=True) score = models.DecimalField(max_digits=5, decimal_places=1, null=True) status = models.IntegerField(choices=STATUS_CHOICES, default=0) class AnswerDetail(models.Model): record = models.ForeignKey(ExamRecord, related_name='answers', on_delete=models.CASCADE) question = models.ForeignKey(Question, on_delete=models.CASCADE) student_answer = models.TextField(blank=True) is_correct = models.BooleanField(null=True) score = models.DecimalField(max_digits=5, decimal_places=1, null=True) class Meta: unique_together = ('record', 'question')设计思路是:一次考试 = 一条 ExamRecord + 多行 AnswerDetail。判分时遍历 AnswerDetail 算总分再回填到 ExamRecord。手动阅卷时老师改的也是 AnswerDetail 里的 score,改完重新聚合总成绩。状态字段 status 则贯穿整个生命周期,从开始考试进入“考试中”,提交后客观题多的进入“已完成”,含主观题的进入“待阅卷”,老师打完分再变成“已完成”。
3.3 Django ORM 高频查询与性能细节
Django ORM 用好了能让代码清爽,用不好也能让数据库崩溃。这个项目里我总结出四种必须掌握的写法。
第一,关联查询要会用 select_related 和 prefetch_related。判分时我需要遍历答题明细再取出每道题的 q_type 和 score,如果直接record.answers.all()再加detail.question,每条明细都会触发一次 SQL,一百道题就是一百次查询。改成record.answers.select_related('question'),一条 JOIN SQL 全部搞定。
第二,批量更新要会用 F 表达式。统计某场考试所有学生的平均分、最高分这类聚合查询,用 annotate 和 aggregate:
from django.db.models import Avg, Max, Count # exams/views.py stat = ExamRecord.objects.filter(paper=paper).aggregate( avg_score=Avg('score'), max_score=Max('score'), cnt=Count('id') )F 表达式则常用于并发场景。比如多个老师同时给同一个学生的试卷打分,如果用“读取 score → 加新分 → 写回”这种三步操作,后写的人会覆盖先写的人。用F('score') + value可以把这个运算下推到数据库执行,避免中间态丢失。
第三,删除对象要批量执行而不是循环。要清理某场考试所有未开始的状态记录,一行代码:
ExamRecord.objects.filter(paper=paper, status=0).delete()这是 Django 官方文档最典型的删除示例,比 for 循环里逐条 delete 高效得多,也避免中途报错导致前删后不删的脏数据。
第四,警惕order_by('?')。随机抽题很多人直接写成Question.objects.filter(...).order_by('?')[:10],这个写法在小数据量时没问题,但题库超过几万条,数据库要对全表做随机排序,性能会明显劣化。我组卷时先取出符合条件的题目主键列表,再在内存里用 random.sample 抽样,最后按主键批量取回题目,这样数据库压力小得多。
4. 后端接口与业务逻辑实现
4.1 登录认证与权限校验
网上考试系统的认证我采用最直接的 Token 方案。前端输入用户名密码,后端验证通过后生成一个随机 Token 返回,前端把这个 Token 存进 localStorage,之后每次请求的请求头都带上Authorization: Token xxx。后端通过 Django 自带认证框架生成和校验:
# users/views.py import secrets from django.contrib.auth import authenticate from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt from django.contrib.auth.hashers import make_password from .models import User @csrf_exempt def login(request): username = request.POST.get('username') password = request.POST.get('password') user = authenticate(request, username=username, password=password) if not user: return JsonResponse({'msg': '用户名或密码错误'}, status=401) token = secrets.token_hex(16) # 生产环境这里要把 token 存 Redis/数据库,这里用缓存示意 request.session[f'token_{user.id}'] = token return JsonResponse({ 'token': token, 'role': user.role, 'real_name': user.real_name, })密码我不会明文存储。创建用户时用make_password('初始密码')再入库,校验时 Django 的 authenticate 会自动调用哈希算法比对。老师要是问“密码数据库里是不是明文”,这是必得分点之一。
Token 校验写在一个公共函数里,每个需要登录的视图都先调它。实际项目里更标准的做法是引入 DRF 的 TokenAuthentication 或者 SimpleJWT,课程设计阶段手写 Token 足够,但要能说清楚 Token 和 Session 的区别:Token 无状态、跨端方便;Session 依赖 Cookie,前后端分离时处理起来麻烦。
4.2 自动组卷算法与分数校验
组卷是这个系统最有“算法味道”的部分。教师端配置一张试卷时,会指定各题型题目数量,后端根据这些规则从题库随机抽题并写入 PaperItem。
# exams/services.py import random from django.db import transaction from .models import PaperItem def generate_paper(paper, rules): """ rules 示例:{'single': 20, 'multi': 10, 'judge': 10, 'essay': 2} """ with transaction.atomic(): for q_type, count in rules.items(): pool = list(Question.objects.filter( subject=paper.subject, q_type=q_type ).values_list('id', flat=True)) if len(pool) < count: raise ValueError(f'{paper.subject} 题库中 {q_type} 题目数量不足') selected_ids = random.sample(pool, count) for i, qid in enumerate(selected_ids): q = Question.objects.get(pk=qid) PaperItem.objects.create( paper=paper, question=q, score=q.score, order_no=random.randint(1, 10000) # 随机顺序 ) # 校验总分是否等于设定值 total = sum(item.score for item in paper.items.all()) if total != paper.total_score: raise ValueError(f'总分 {total} 与设定 {paper.total_score} 不一致')这段代码有两个细节值得展开。第一,抽题先取主键列表再随机抽样,而不是直接把 QuerySet 切块,原因在 3.3 提过,大数据量下更稳。第二,组卷过程用transaction.atomic()包裹,保证要么整张卷子生成成功,要么一张 PaperItem 都不写入,不会出现“抽了 20 题写到第 15 题报错,剩 5 题是空的”这种半成品状态。
总分校验是另一个容易被忽略的点。默认每题分数是数据库里固定存的,但老师可能希望单题改分,所以我允许 PaperItem.score 与 Question.score 不同。组卷完成后必须把所有题目分数加起来与 paper.total_score 比对,不一致就抛异常。这个校验逻辑在答辩时非常好讲清楚:它保证了系统不会出现“总分 99 分”的低级错误。
4.3 客观题自动判分与主观题人工阅卷
判分逻辑的核心是区分客观题和主观题。客观题包含单选、多选、判断,答案完全确定,可以自动判;主观题需要老师人工打分。我写了一个 auto_grade 函数来处理:
# exams/services.py from django.db import transaction from django.utils import timezone def auto_grade(record): with transaction.atomic(): total = 0 has_essay = False for detail in record.answers.select_related('question'): q = detail.question student = detail.student_answer.strip().upper() if q.q_type == 'multi': # 多选题答案排序后比较,避免 "ABD" 和 "BAD" 误判 detail.is_correct = sorted(student) == sorted(q.answer.upper()) else: detail.is_correct = student == q.answer.strip().upper() if q.q_type == 'essay': has_essay = True detail.is_correct = None detail.score = None else: detail.score = q.score if detail.is_correct else 0 total += detail.score or 0 detail.save(update_fields=['is_correct', 'score']) record.score = total record.submit_at = timezone.now() if has_essay: record.status = 2 # 待阅卷 else: record.status = 3 # 已完成 record.save(update_fields=['score', 'submit_at', 'status'])多选题判分时比较答案排序是一个很实用的细节。学生提交的是 “ABD”,题目标准答案是 “ADB”,如果直接字符串比较就判错了;排序后再比,逻辑上才是“选对了哪些选项”的集合比较。也有考试系统做“少选得一半分”的规则,真要做就在这个函数里加一个分支:两个集合的交集长度和标准答案长度比,大于等于标准答案一半就给半分。这个扩展逻辑说出来,会显得你对业务理解得很细。
主观题的处理则是先把 AnswerDetail 里的 is_correct 和 score 留空,把 ExamRecord 的状态改为“待阅卷”,教师在网页端逐题查看学生答案并填入分数,保存时再对该考生所有明细重新聚合总分。
4.4 Flask 在项目中的辅助业务落地
Flask 辅助服务我放在flask_export/app.py,它读取与 Django 同一个数据库,对外提供两个接口。第一个是成绩导出:
# flask_export/app.py from flask import Flask, request, send_file from openpyxl import Workbook from io import BytesIO app = Flask(__name__) @app.route('/export/grades') def export_grades(): paper_id = request.args.get('paper_id', type=int) # 这里实际应该校验调用方身份,生产环境不能裸奔 wb = Workbook() ws = wb.active ws.append(['学号', '姓名', '得分']) # 从数据库查询该场考试所有学生成绩并填充 bio = BytesIO() wb.save(bio) bio.seek(0) return send_file( bio, mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', as_attachment=True, download_name='grades.xlsx' )有同学看到这里会问,Flask 怎么知道前端发来的 request 是 GET 还是 POST、参数是什么类型?调试时最直接的办法是在接口里打印request.method和type(request.args.get('paper_id')),Flask 的 request 对象在请求进来时自动填充,你只用关注业务处理,不用管底层 Socket 的事情。
另一个接口是自动收卷任务,用 APScheduler 定时执行:
# flask_export/jobs.py from apscheduler.schedulers.background import BackgroundScheduler from exams.models import ExamRecord # 这里通过 Django settings 初始化环境 def sweep_expired_exams(): now = timezone.now() expired = ExamRecord.objects.filter( paper__end_time__lt=now, paper__published=True, status__in=[0, 1] ) for record in expired: if record.status == 0: record.status = 3 record.score = 0 else: # 没有主动提交但考试结束的,按“已答客观题”判分 auto_grade(record) record.save() scheduler = BackgroundScheduler() scheduler.add_job(sweep_expired_exams, 'interval', minutes=1) scheduler.start()这里要同步 Django 的模型,只需在 Flask 进程启动时配置好DJANGO_SETTINGS_MODULE环境变量并调用django.setup(),Flask 就可以直接操作 Django ORM 的模型了。启动 Flask 用flask --app app run --port 5000,前端通过 Vue 代理转发/export路径到 5000 端口。这样一来,Django 只管实时交互业务,Flask 管导出和定时任务,整个系统职责清晰,也给了你好几个可以写进文档的架构决策点。
5. 前端 Vue 核心页面与交互细节
5.1 考试倒计时、答题卡与人机交互
考试页面是整个前端最复杂的页面,它的核心不是“显示题目”,而是“控制时间和状态”。页面布局我分为左右两栏:左侧显示题目内容和选项,右侧是答题卡面板。答题卡按题目序号排列,未作答显示灰色,已作答变成蓝色,当前正在看的题目高亮边框。用 Vue 实现这个效果的核心是一个对象answerMap,key 是题目 id,value 是学生选择:
data() { return { questionList: [], answerMap: {}, currentIndex: 0, remaining: 0, examEndTime: Date.now() } }倒计时逻辑要特别注意,不能用纯前端setInterval每秒减一,而是要根据后端返回的考试结束时间戳计算剩余秒数。原因很简单:学生本地电脑时间可以随意改,如果前端闭着眼睛倒计时,改一下系统时间就能无限续考。正确做法是进入考试时后端返回end_time时间戳,前端每秒用examEndTime - Date.now()计算剩余并刷新显示,倒计时归零时立刻调用提交接口。
let timerId = null this.remaining = Math.floor((this.examEndTime - Date.now()) / 1000) timerId = setInterval(() => { this.remaining -= 1 if (this.remaining <= 0) { clearInterval(timerId) this.submitExam() } }, 1000)答题卡点击某项时更新answerMap并记录当前高亮序号。多选题我用一组 checkbox,判断选项是否被选中,最后把选中的字母拼接成字符串提交。判断题用“正确/错误”两个按钮切换。为了防止误触刷新丢失答案,我在每题作答后把answerMap实时写入 localStorage,并在页面离开时弹出确认框提示“考试尚未提交,确认离开吗?”。
5.2 Axios 封装、防重复提交与刷新兜底
前端所有请求都经由统一封装的 request.js,请求拦截器负责把 Token 附加到请求头,响应拦截器负责统一处理 401 和业务错误码:
// src/utils/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 = 'Token ' + token return config }) request.interceptors.response.use( res => res.data, err => { if (err.response && err.response.status === 401) { localStorage.removeItem('token') location.href = '/login' } return Promise.reject(err) } )防重复提交是我在联调期间踩过的大坑。考试倒计时归零时自动交卷和学生手动点“交卷”按钮同时触发,后端可能收到两次提交请求,第一次把状态改成“已完成”,第二次进来发现状态不是“考试中”又给打了个零分,学生成绩就被洗没了。前端层面我在交卷函数里加一个submitting标志,提交期间按钮禁用:
async submitExam() { if (this.submitting) return this.submitting = true try { await submitExamAPI(this.paperId, this.answerMap) localStorage.removeItem('exam_backup_' + this.paperId) this.$router.push('/student/result') } finally { this.submitting = false } }但前端标志挡不住恶意请求或网络重放,真正的防护必须放在后端:提交接口用“CAS 式更新”判断状态,ExamRecord.objects.filter(pk=record_id, status=1).update(status=3)返回更新行数,行数为 0 说明已经被提交过,直接拒绝。这个写法我在第 6 部分还会作为并发处理重点展开。
刷新兜底的意义在于:学生真的不小心刷新了页面,只要考试还在有效期,就从 localStorage 恢复答案并重新进入考试页;一旦正式提交成功,立即清除本地备份,避免交卷以后又“恢复”出一份答案造成二次提交。
5.3 教师端管理页面的组件化拆解
管理端的核心页面有三个:题库管理、试卷配置、人工阅卷。题库管理页用 Element Plus 的 table 展示题目列表,顶部放筛选栏,按学科、题型、难度过滤;每一行提供编辑和删除按钮。新增和编辑弹窗抽取成QuestionFormDialog组件,内部根据题型动态渲染选项输入区——单选题选项用多个输入框,判断题只给“正确/错误”两个选项选择。
试卷配置页交互稍微复杂一点。我拆成三个步骤的向导:第一步选学科、填标题、考试时长、起止时间;第二步设置组卷规则,即每种题型的数量;第三步点击“生成试卷”调用后端接口,返回结果后展示 PaperItem 的预览列表,老师可以手动删除或调整个别题目。这个页面用到 Element Plus 的 Steps、Form、Table 三个组件,代码量虽大,但组件化以后每个步骤一个子组件,复杂度被拆解得很清晰。
人工阅卷页面接收后端返回的“待阅卷”记录列表。点开某条记录,左侧显示学生答案文本,右侧显示对应的主观题题干,老师输入得分并点击保存,后端立即重算该考生总分。这里的关键是 URL 路由参数的使用,前端跳转到阅卷详情页:
this.$router.push({ name: 'grade-detail', params: { recordId: row.id } })详情页里用useRoute().params.recordId获取参数,再调用/api/exams/grades/{recordId}接口。Vue Router 的 params 传参在刷新后会丢失,所以更稳妥的做法是跳转时也把 recordId 写到 URL query 里,刷新页面后从this.$route.query.recordId重新读取。这个细节我在开发时吃了不少亏,现在写下来提醒大家。
6. 常见问题排查与避坑实录
6.1 从报错到解决:高频问题速查表
我把开发过程中遇到的高频问题整理成了速查表,很多问题都是课程设计群体里的“通病”:
| 现象 | 根本原因 | 解决方案 |
|---|---|---|
| ModuleNotFoundError: No module named 'pymysql' | 没有安装 MySQL 驱动 | pip install pymysql,在 Django 项目的__init__.py里加pymysql.install_as_MySQLdb() |
| 数据库写入中文变问号 | 连接字符集不是 utf8mb4 | 在 DATABASES 配置中加'OPTIONS': {'charset': 'utf8mb4'} |
| 前端请求 /api 报 CORS 错误 | 跨域没有配置 | 安装 django-cors-headers,把前端地址加入 CORS_ALLOWED_ORIGINS |
| Vue 项目刷新页面 404 | 路由使用了 history 模式且服务器没有 fallback | 简单方案改 hash 模式,或在 Nginx 配 try_files |
| npm install 报 ERESOLVE 错误 | npm 7+ 对 peerDependencies 检查过严 | 加--legacy-peer-deps参数 |
| 图片上传后前端 404 | MEDIA_URL 和 MEDIA_ROOT 没配置或未映射 URL | 见 6.2 小节,完整配置 + urls.py 挂载 |
| PyCharm 运行后修改代码不生效 | 没有用 Debug 模式的热重载 | 确认启动方式为python manage.py runserver,它自带 autoreload |
| 考试时间显示差了 8 小时 | USE_TZ 开启但展示区没转本地时区 | 前端用 new Date(后端时间戳) 本地格式化,后端展示接口timezone.localtime() |
| Flask 接口返回数据在前端不显示 | 前后端字段名不一致 | 在后端打印 request 参数和前端实际发送的数据对比,逐步定位 |
6.2 细说 Django static 与上传文件显示问题
这个坑几乎每个人都会踩一遍,尤其是新手用 VSCode 写<img src="/static/xxx.png">却发现图片加载不出来,问遍了一圈最后发现是静态文件配置不对。先看 settings.py 里有没有这段:
STATIC_URL = '/static/' STATICFILES_DIRS = [ BASE_DIR / 'static', ] MEDIA_URL = '/media/' MEDIA_ROOT = BASE_DIR / 'media'STATICFILES_DIRS告诉 Django 到哪个目录去找静态资源,STATIC_URL是浏览器访问这些资源的 URL 前缀。如果模板里要引用,必须先{% load static %}再写{% static 'images/logo.png' %},直接写死路径src="/static/images/logo.png"在 DEBUG=True 时偶尔能跑,但一旦换到生产环境做 collectstatic 就会出问题。
对于用户上传的文件,比如学生头像、教师课件图片,它们不属于“静态资源”,而是“媒体文件”,必须在urls.py里做一层映射:
# exam_backend/urls.py from django.conf import settings from django.conf.urls.static import static urlpatterns = [ # ... 你的接口路由 ] if settings.DEBUG: urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)这一行不加,/media/开头的 URL 永远 404。这个知识点考试不一定会考,但项目答辩时老师最喜欢问:“你上传的文件存哪了?上线以后怎么访问?”能把这套 static/media 机制讲清楚,说明你对 Django 静态文件处理是真正理解的。
6.3 考试并发场景下的数据一致性处理
在线考试的并发问题主要集中在两点:一是多个学生同时交卷,二是同一个学生重复交卷。先看重复交卷,我的后端提交逻辑用了条件更新原子操作:
# exams/views.py from django.db import transaction @require_role('student') def submit_exam(request, paper_id): if request.method != 'POST': return JsonResponse({'msg': '方法不允许'}, status=405) record = ExamRecord.objects.get( student=request.user, paper_id=paper_id, status=1 ) if not record: return JsonResponse({'msg': '没有进行中的考试记录'}, status=400) answers = request.POST.get('answers') # JSON 字符串 with transaction.atomic(): # 关键:用 update 加条件,同一时刻只有一个人能成功 updated = ExamRecord.objects.filter( pk=record.pk, status=1 ).update(status=2) if updated != 1: return JsonResponse({'msg': '试卷已提交,请勿重复操作'}, status=400) # 写入答题明细 for qid, ans in json.loads(answers).items(): AnswerDetail.objects.update_or_create( record=record, question_id=qid, defaults={'student_answer': ans} ) # 重新拉到最新状态,调用自动判分 auto_grade(record) return JsonResponse({'msg': '提交成功'})filter(status=1).update(status=2)这行是关键:它把“判断状态”和“更新状态”合并为一个原子 SQL,数据库行锁保证再快的重复请求也只有一个能成功。如果先查再改,两个请求同时读到 status=1,就都可能进入后续逻辑,造成成绩被覆盖或双倍写入。
多学生同时交卷造成的数据竞争,除了 update_or_create 保证明细不重复,还可以给 AnswerDetail 加唯一约束(unique_together),这样即使并发中出现了两条同样的 record+question,数据库也会帮你挡住第二条。另外,主观题人工打分和自动判分同时发生时,我用事务包裹所有明细写入和总分更新,保证一个学生成绩的“明细”和“总分”永远一致。这些细节虽然在简单演示数据量下看不出差别,但能体现你对数据并发问题的理解层次,也是课程设计里最容易拿分的亮点。
关于自动收卷,还有一个场景值得留意:考试时间到但学生页面还没交卷,Flask 定时任务已经帮他把状态改成了已完成,学生随后手动点交卷会收到“试卷已提交”的提示,这个提示要写得友好一点,不要让考生以为系统吞了他的卷子。我在前端把这种提示统一处理为“考试已结束,成绩已自动生成”,从用户体验上堵住投诉来源。
做完整套系统,我最大的体会是:网上考试系统的技术难点从来不在“CRUD”本身,而在于考试这种业务场景对“时间”和“状态”极度敏感。倒计时归零的一瞬间到底以谁的时钟为准?交卷请求到底算不算数?成绩正在被老师批改时学生能不能查?这些边界情况比正常路径复杂得多。我建议所有做同类项目的朋友,拿到需求先画状态机,把每条状态流转的触发条件写清楚,再动手写代码,能少走一大半弯路。
最后再分享一个实用技巧:开发联调阶段不要嫌麻烦,一定要在 PyCharm 里给 Django 和 Flask 分别配置 Run Configuration,并在 Vue 的 devServer 里配好代理。三端分开启动,每次改动只需要重启对应进程,比把服务全塞在一个进程里调试舒服得多。等你把这套流程跑顺,就会发现前后端分离的开发节奏一旦建立起来,后续加个新页面、新接口,基本就是复制粘贴再微调的体力活了。