资讯动态

Python+Vue网上考试系统开发实战:Django与Flask分工协作

发布时间:2026/9/26 5:28:46 来源:尧图企业网站定制
做网上考试系统很多第一次上手的人会觉得“不就是一个答题网页加个数据库嘛”。真正把功能做完整才发现光“试卷怎么来、考完怎么判、成绩怎么算、怎么防止有人卡点交卷卡出问题”这四件事就能把人折腾到凌晨。我这次用 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 装了个新包把依赖搞坏了”的惨剧虚拟环境能把这层风险隔离开。进入虚拟环境后安装依赖我会把核心包列一个清单包名版本建议用途Django4.x主后端框架djangorestframework3.14提供 API 开发支持可不装但推荐django-cors-headers4.x解决前后端跨域Flask3.x辅助服务openpyxl3.1成绩导出 ExcelPyMySQL1.xMySQL 驱动APScheduler3.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 里几十张表混在一堆文件里后续改一个字段要找半天。我的习惯是一个业务域一个 appusers 管用户和权限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 vuelatest 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.jsconst { 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_length10, choicesROLE_CHOICES, defaultstudent) real_name models.CharField(max_length50, blankTrue) class Meta: db_table sys_user权限控制这块很多人提到 RBACRole-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: 未登录}, status401) if request.user.role not in roles and not request.user.is_superuser: return JsonResponse({msg: 无权限}, status403) 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_deletemodels.CASCADE) q_type models.CharField(max_length10, choicesTYPE_CHOICES) content models.TextField() options models.JSONField(nullTrue, blankTrue) answer models.CharField(max_length50) score models.DecimalField(max_digits4, decimal_places1, default2.0) difficulty models.IntegerField(default3)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_length100) subject models.ForeignKey(Subject, on_deletemodels.CASCADE) total_score models.IntegerField(default100) duration models.IntegerField(help_text考试时长分钟) start_time models.DateTimeField() end_time models.DateTimeField() published models.BooleanField(defaultFalse) class PaperItem(models.Model): paper models.ForeignKey(Paper, related_nameitems, on_deletemodels.CASCADE) question models.ForeignKey(Question, on_deletemodels.CASCADE) score models.DecimalField(max_digits4, decimal_places1) order_no models.IntegerField(default0)考试记录和答题明细则是考试过程的“流水账”class ExamRecord(models.Model): STATUS_CHOICES ((0, 未开始), (1, 考试中), (2, 待阅卷), (3, 已完成)) student models.ForeignKey(User, on_deletemodels.CASCADE) paper models.ForeignKey(Paper, on_deletemodels.CASCADE) start_at models.DateTimeField(nullTrue) submit_at models.DateTimeField(nullTrue) score models.DecimalField(max_digits5, decimal_places1, nullTrue) status models.IntegerField(choicesSTATUS_CHOICES, default0) class AnswerDetail(models.Model): record models.ForeignKey(ExamRecord, related_nameanswers, on_deletemodels.CASCADE) question models.ForeignKey(Question, on_deletemodels.CASCADE) student_answer models.TextField(blankTrue) is_correct models.BooleanField(nullTrue) score models.DecimalField(max_digits5, decimal_places1, nullTrue) 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 和 aggregatefrom django.db.models import Avg, Max, Count # exams/views.py stat ExamRecord.objects.filter(paperpaper).aggregate( avg_scoreAvg(score), max_scoreMax(score), cntCount(id) )F 表达式则常用于并发场景。比如多个老师同时给同一个学生的试卷打分如果用“读取 score → 加新分 → 写回”这种三步操作后写的人会覆盖先写的人。用F(score) value可以把这个运算下推到数据库执行避免中间态丢失。第三删除对象要批量执行而不是循环。要清理某场考试所有未开始的状态记录一行代码ExamRecord.objects.filter(paperpaper, status0).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, usernameusername, passwordpassword) if not user: return JsonResponse({msg: 用户名或密码错误}, status401) token secrets.token_hex(16) # 生产环境这里要把 token 存 Redis/数据库这里用缓存示意 request.session[ftoken_{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( subjectpaper.subject, q_typeq_type ).values_list(id, flatTrue)) 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(pkqid) PaperItem.objects.create( paperpaper, questionq, scoreq.score, order_norandom.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, typeint) # 这里实际应该校验调用方身份生产环境不能裸奔 wb Workbook() ws wb.active ws.append([学号, 姓名, 得分]) # 从数据库查询该场考试所有学生成绩并填充 bio BytesIO() wb.save(bio) bio.seek(0) return send_file( bio, mimetypeapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheet, as_attachmentTrue, download_namegrades.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__ltnow, paper__publishedTrue, 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, minutes1) 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 实现这个效果的核心是一个对象answerMapkey 是题目 idvalue 是学生选择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(pkrecord_id, status1).update(status3)返回更新行数行数为 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_ORIGINSVue 项目刷新页面 404路由使用了 history 模式且服务器没有 fallback简单方案改 hash 模式或在 Nginx 配 try_filesnpm install 报 ERESOLVE 错误npm 7 对 peerDependencies 检查过严加--legacy-peer-deps参数图片上传后前端 404MEDIA_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 / mediaSTATICFILES_DIRS告诉 Django 到哪个目录去找静态资源STATIC_URL是浏览器访问这些资源的 URL 前缀。如果模板里要引用必须先{% load static %}再写{% static images/logo.png %}直接写死路径src/static/images/logo.png在 DEBUGTrue 时偶尔能跑但一旦换到生产环境做 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_rootsettings.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: 方法不允许}, status405) record ExamRecord.objects.get( studentrequest.user, paper_idpaper_id, status1 ) if not record: return JsonResponse({msg: 没有进行中的考试记录}, status400) answers request.POST.get(answers) # JSON 字符串 with transaction.atomic(): # 关键用 update 加条件同一时刻只有一个人能成功 updated ExamRecord.objects.filter( pkrecord.pk, status1 ).update(status2) if updated ! 1: return JsonResponse({msg: 试卷已提交请勿重复操作}, status400) # 写入答题明细 for qid, ans in json.loads(answers).items(): AnswerDetail.objects.update_or_create( recordrecord, question_idqid, defaults{student_answer: ans} ) # 重新拉到最新状态调用自动判分 auto_grade(record) return JsonResponse({msg: 提交成功})filter(status1).update(status2)这行是关键它把“判断状态”和“更新状态”合并为一个原子 SQL数据库行锁保证再快的重复请求也只有一个能成功。如果先查再改两个请求同时读到 status1就都可能进入后续逻辑造成成绩被覆盖或双倍写入。多学生同时交卷造成的数据竞争除了 update_or_create 保证明细不重复还可以给 AnswerDetail 加唯一约束unique_together这样即使并发中出现了两条同样的 recordquestion数据库也会帮你挡住第二条。另外主观题人工打分和自动判分同时发生时我用事务包裹所有明细写入和总分更新保证一个学生成绩的“明细”和“总分”永远一致。这些细节虽然在简单演示数据量下看不出差别但能体现你对数据并发问题的理解层次也是课程设计里最容易拿分的亮点。关于自动收卷还有一个场景值得留意考试时间到但学生页面还没交卷Flask 定时任务已经帮他把状态改成了已完成学生随后手动点交卷会收到“试卷已提交”的提示这个提示要写得友好一点不要让考生以为系统吞了他的卷子。我在前端把这种提示统一处理为“考试已结束成绩已自动生成”从用户体验上堵住投诉来源。做完整套系统我最大的体会是网上考试系统的技术难点从来不在“CRUD”本身而在于考试这种业务场景对“时间”和“状态”极度敏感。倒计时归零的一瞬间到底以谁的时钟为准交卷请求到底算不算数成绩正在被老师批改时学生能不能查这些边界情况比正常路径复杂得多。我建议所有做同类项目的朋友拿到需求先画状态机把每条状态流转的触发条件写清楚再动手写代码能少走一大半弯路。最后再分享一个实用技巧开发联调阶段不要嫌麻烦一定要在 PyCharm 里给 Django 和 Flask 分别配置 Run Configuration并在 Vue 的 devServer 里配好代理。三端分开启动每次改动只需要重启对应进程比把服务全塞在一个进程里调试舒服得多。等你把这套流程跑顺就会发现前后端分离的开发节奏一旦建立起来后续加个新页面、新接口基本就是复制粘贴再微调的体力活了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑