资讯动态

Django+Vue构建在线考试系统:从题库设计到自动判分的完整实践

发布时间:2026/10/14 6:20:48 来源:尧图企业网站定制
我前几个月接了一个活儿帮一家职业技能培训机构做在线试题库考试系统。需求听起来不复杂老师传题学生考试系统自动出分。但真正动手以后才发现从题库怎么组织、试卷怎么生成、考试中途断网怎么办到自动判分怎么处理每一环都有不少讲究。这篇文章我把整个从设计到开发、从联调到部署的复盘整理出来涉及Django后端、Vue前端、PyCharm开发的全过程希望能给正在做类似东西的人一些参考。项目本身我用了Python Django写后端接口前端用Vue做单页应用整个开发调试在PyCharm里完成。之所以选前后端分离而不是Django模板渲染是为了考试页面的交互体验——倒计时、自动保存、题目切换都不需要整页刷新。下面按我的实际推进顺序把这套系统的关键设计和技术实现拆开讲。1. 拆需求时我确定的几条硬约束从业务角度反推技术选型1.1 三类角色和一个特殊场景为什么不是简单CRUD在线试题库考试系统听起来就像“题库试卷成绩”三个模块的增删改查但真实业务里角色不止一种流程也远比CRUD复杂。我梳理下来系统至少要服务三类人考生要能参加考试、查看成绩和错题教师要能维护题库、创建试卷、批阅主观题、查看成绩统计管理员则要管理教师账号、考试场次和系统参数。三类角色有完全不同的操作界面也有完全不同的权限边界。另一个容易忽略的点是“考试”这个特殊场景。普通的信息管理系统允许用户随意修改数据但一场考试一旦开始试卷内容就不能变考生答案要持续保存交卷之后也不能轻易撤销。这意味着系统在数据层面需要“快照”机制在接口层面需要有状态控制而不是开放所有表给前端随便改。所以我给项目定的第一条硬约束就是数据库设计先于接口设计考试状态机必须清晰。很多类似的开发项目最后做乱了问题都出在没想清楚“一场考试从创建到判分到底经历哪些状态”。1.2 Django、Flask与Vue的组合逻辑后端选Django还是Flask是我在动手前比较纠结的一个点。两个框架都能实现这套系统但适合的节奏完全不同。Django的优势在于“全家桶”自带ORM、Admin后台、认证体系对于这类以数据管理为主的业务系统节省的时间非常可观。尤其是题库管理如果用Django自带的Admin再加一层简单封装教师端的大部分维护功能甚至不用单独写页面。Flask的优势是轻量、自由路由和中间件都能完全掌控但用户管理、ORM、序列化、迁移这些都要自己拼装。一个在线考试系统涉及用户、试题、试卷、答卷、成绩多张表的关联操作我并不想在搭地基上花太多时间所以最终选了Django Django REST Framework。前端选择Vue主要是因为它的生态成熟、上手快而且考试页面的交互可以做到非常精细。Vue响应式的数据绑定让答题状态的维护很自然考生选了单选题界面立刻高亮多选判断的逻辑也可以通过computed属性轻松完成。技术栈最终定为后端Python 3.10 Django 4.x Django REST Framework SimpleJWT前端Vue 3 Vite Vue Router Axios Element Plus数据库开发阶段用SQLite正式部署切MySQL需要说明的是Flask方案完全可以实现同样的功能只是组织方式不同。如果你偏好自己掌控一切用Flask Flask-SQLAlchemy Flask-JWT-Extended也可以核心的组卷和判分逻辑两个框架是通用的。1.3 PyCharm在整个开发流程里扮演的角色标题里特别提到了PyCharm这也是我实际开发的主力工具。像这类前后端分离项目通常要同时开两个服务Django跑在8000端口Vite跑在5173端口PyCharm比较好的地方是它可以同时管理两个终端、两个调试器。我在PyCharm里建了统一的项目目录backend和frontend两个子项目。Django部分用Run Configuration直接点调试断点打在后端视图函数里非常方便Vue部分在Terminal里跑npm命令。前后端联调时后端接口断点、前端请求日志可以同时看到定位问题比两个IDE来回切高效得多。还有一个小经验PyCharm的Database工具可以直接看SQLite或MySQL的表结构和数据调试判分逻辑时我需要反复确认某条答题记录的类型和答案格式直接在IDE侧边栏查库比写SQL再打印出来省事。哪怕只是用免费版开发Python再用浏览器开发者工具配合调前端也完全能推进但效率确实不如全家桶。2. 建表之前先把这三件事想清楚题库、试卷、答卷的数据形态数据模型是整个系统的地基。我一开始以为题库表就是“题目ID、题目内容、答案、难度”结果建表时发现每个字段都有讲究。2.1 题目表题型、难度、答案与解析的存储设计题库核心表我命名为Question主要字段如下class Question(models.Model): SINGLE single MULTIPLE multiple JUDGE judge TEXT text TYPE_CHOICES [ (SINGLE, 单选题), (MULTIPLE, 多选题), (JUDGE, 判断题), (TEXT, 简答题), ] question_type models.CharField(max_length20, choicesTYPE_CHOICES) content models.TextField() options models.JSONField(defaultlist) answer models.TextField(blankTrue) analysis models.TextField(blankTrue) difficulty models.IntegerField(default3) knowledge_point models.CharField(max_length128) course models.ForeignKey(Course, on_deletemodels.CASCADE) creator models.ForeignKey(User, on_deletemodels.PROTECT) is_active models.BooleanField(defaultTrue) created_at models.DateTimeField(auto_now_addTrue)这里有两个设计决策值得展开。第一题目选项用JSONField存储而不是另建一张选项表。单道题的选项数量通常不多以JSON列表存储既能保留顺序又能直接对应前端渲染还方便导入导出。判分时读取question.answer与考生答案比较即可不需要跨表查询。第二答案字段没有用统一格式而是按题型做约定。单选题存“A”多选题存“[A,C]”判断题存“对/错”或“T/F”简答题存参考答案文本。约定清晰后自动判分逻辑只需按题型分别处理。扩展知识点的字段也很有用虽然它不影响存储但在组卷时是重要的抽题维度。难度我用了1到5的整数默认3这样自动组卷可以方便地按难度权重控制比例。2.2 试卷表为什么要“留快照”试卷不是简单的“题目集合”它有两种生成方式手动选题和自动组卷。不管哪一种只要试卷发布、考生开始考试题目就不能再变。否则考生A看到的题和考生B看到的题不一样一场考试就失去公平性了。所以我在Paper表之外还设计了PaperQuestion中间表含义是“试卷当时的完整快照”class Paper(models.Model): title models.CharField(max_length128) duration models.IntegerField(help_text考试时长单位分钟) total_score models.IntegerField(default100) paper_type models.CharField(max_length20) is_published models.BooleanField(defaultFalse) creator models.ForeignKey(User, on_deletemodels.PROTECT) created_at models.DateTimeField(auto_now_addTrue) class PaperQuestion(models.Model): paper models.ForeignKey(Paper, on_deletemodels.CASCADE) question models.ForeignKey(Question, on_deletemodels.CASCADE) order models.IntegerField() score models.IntegerField()这样做的原因是题目库里的题可以被教师随时修改如果考生正在考试时教师把某道题改了个选项考生页面上的题目却不一致这就是严重事故。通过PaperQuestion把“考的是哪些题、每题多少分、按什么顺序”固定下来和题库的后续改动彻底解耦。有人会问为什么不在Paper表里直接存一个JSON大字段包含全部题目实际上也可以但中间表更方便做考试成绩的统计分析比如统计某道题的正确率时需要按question_id聚合。PaperQuestion正好承担这个职责。2.3 考试记录与答题明细自动判分的数据基石考生点击“开始考试”时系统要生成一条考试记录ExamRecord状态为“考试中”。考生每答一题系统把答案保存到ExamAnswer明细表。交卷时根据PaperQuestion中的题目和答案逐题判分并写回明细表最后汇总总分。class ExamRecord(models.Model): STATUS_CHOICES [ (examining, 考试中), (submitted, 已交卷), (graded, 已判分), (abandoned, 缺考), ] user models.ForeignKey(User, on_deletemodels.CASCADE) paper models.ForeignKey(Paper, on_deletemodels.CASCADE) status models.CharField(max_length20, choicesSTATUS_CHOICES, defaultexamining) start_time models.DateTimeField(auto_now_addTrue) submit_time models.DateTimeField(nullTrue, blankTrue) score models.IntegerField(nullTrue, blankTrue) class ExamAnswer(models.Model): record models.ForeignKey(ExamRecord, on_deletemodels.CASCADE, related_nameanswers) paper_question models.ForeignKey(PaperQuestion, on_deletemodels.CASCADE) user_answer models.TextField(blankTrue) is_correct models.BooleanField(nullTrue) score models.IntegerField(default0)这套设计最关键的一点是自动判分时不用对比原题只需要遍历考生所在record的answers根据paper_question关联到的question类型做匹配。主观题则单独设置is_correct为NULL分数由教师在后台人工录入。3. 后端三条主线的实现细节鉴权、组卷、判分后端接口我分成三条主线身份鉴权、试卷生成、答卷判分。每一条都有一些容易出错的细节。3.1 基于JWT的角色鉴权前后端分离项目里最常用的方案是JWT。用户登录后后端签发access token和refresh token前端把token存在内存或本地存储中请求时放到Authorization头里。我使用的是djangorestframework-simplejwt。Django自带的认证后端默认基于User模型而我需要区分考生和教师最简单的方式是在自定义User模型上加一个role字段from django.contrib.auth.models import AbstractUser class User(AbstractUser): ROLE_CHOICES [ (student, 考生), (teacher, 教师), (admin, 管理员), ] role models.CharField(max_length20, choicesROLE_CHOICES, defaultstudent)然后在视图里通过自定义权限类控制访问范围from rest_framework.permissions import BasePermission class IsTeacher(BasePermission): def has_permission(self, request, view): return request.user.is_authenticated and request.user.role teacher题库管理、试卷创建接口只对教师和管理员开放考试接口对考生开放。Admin后台则通过is_staff字段控制。JWT需要注意的点是token过期时间。考试一场动辄几十分钟如果access token设成30分钟考试到一半过期答题保存接口就会401。我直接把access token有效期调成了4小时refresh token保持7天。安全性上有所妥协但对于局域网内的小规模考试系统完全够用如果要上公网建议改用access 30分钟 refresh自动刷新前端拦截401后静默更新token。3.2 自动组卷的加权随机算法组卷是让教师省力气的核心功能。手动组卷就是教师到题库里勾选题目这部分我用Django Admin加一个自定义表单页就实现了。自动组卷则需要按教师的设定从题库抽取题目通常有两个抽题维度知识点和难度。我的算法思路是教师指定考试总分、每种题型的题量、难度分布、需要覆盖的知识点列表。系统按知识点分桶在每个知识点内按难度比例随机抽取避免连续抽中难题或全抽简单题。import random def auto_generate_paper(rule): picked_ids [] for point in rule[knowledge_points]: for difficulty in range(1, 6): count rule[difficulty_counts][difficulty] if count 0: continue available list( Question.objects.filter( knowledge_pointpoint, difficultydifficulty, is_activeTrue ).values_list(id, flatTrue) ) valid_ids [qid for qid in available if qid not in picked_ids] if len(valid_ids) count: raise ValueError(f知识点【{point}】难度{difficulty}题目不足) picked_ids.extend(random.sample(valid_ids, count)) return picked_ids实际代码里我还会按course过滤因为一次考试通常只考单门课程。抽完题之后后端把选中题目按题型次序写入PaperQuestion并保存总分。这里一定要在同一个事务里完成防止试卷头写入了但题目明细只写入一半。组卷算法的补充说明如果题目总数充足简单随机就能满足需求。但总分会不符合要求比如抽到的题累加分数不是100。更稳妥的做法是先按“题型固定题数和单题分值”计算总分再在抽题时只筛题型和知识点不管单题分值。这样总分会始终可控。3.3 提交试卷后的自动判分流程交卷接口是整套系统的枢纽它承担的不只是“把status改成已交卷”还要逐题判分、汇总成绩。我实现的自动判分逻辑按题型区别处理def score_one_answer(paper_question, user_answer): q paper_question.question current_score 0 is_correct False if q.question_type multiple: # 多选题集合相等才判对 correct set(q.answer.replace(, ,).split(,)) user set(user_answer.replace(, ,).split(,)) is_correct correct user current_score paper_question.score if is_correct else 0 elif q.question_type in (single, judge): is_correct q.answer.strip() user_answer.strip() current_score paper_question.score if is_correct else 0 elif q.question_type text: # 简答题暂时不自动判分由教师人工评分 is_correct None current_score 0 return is_correct, current_score多选题判分是个容易被坑的地方学生的作答顺序可能和答案顺序不一致比如正确答案是A和C学生选了C和A直接字符串比较会误判。所以必须统一转换成集合再比较。客观题全部判完后汇总客观题得分再把主观题标记为待批阅ExamRecord总分会先填入客观题得分等教师在后台录入主观题分数之后再重新计算总成绩。为了防止交卷请求被重复执行我用了状态机加乐观锁。核心是一次原子更新updated ExamRecord.objects.filter( idrecord_id, userrequest.user, statusexamining ).update(statussubmitted, submit_timetimezone.now()) if updated 0: return Response({detail: 该记录已交卷请勿重复操作}, status400)Django的update方法会生成UPDATE ... WHERE的SQL语句where条件里包含了statusexamining。即使前端连续发了两个交卷请求数据库也只会让第一个请求把状态从examining改成submitted第二个请求发现没有“状态为examining”的记录直接拒绝。4. Vue前端如何把考试体验做顺路由、状态与倒计时4.1 前端项目的目录与路由划分Vue端我按角色拆功能接口用统一的axios实例封装。目录结构大概是frontend/src/ ├── api/ │ ├── auth.js │ ├── paper.js │ └── exam.js ├── router/ │ └── index.js ├── views/ │ ├── login/ │ ├── student/ │ │ ├── ExamPage.vue │ │ └── ResultPage.vue │ └── teacher/ │ ├── QuestionManage.vue │ └── PaperManage.vue路由使用动态路由配合路由守卫。登录后根据用户角色加载不同的路由表考生默认跳转待考列表教师跳转题库管理。路由守卫里检查是否有token没有token的强制跳登录页。4.2 答题页面的交互状态管理答题页面是前端最复杂的部分。一个页面要同时管理当前正在作答的题目、整个试卷的答题状态、剩余时间以及交卷逻辑。我没有引入Vuex或Pinia做全局状态因为考试页的答题状态基本只属于这个页面组件内部用组合式API的reactive对象管理就够了。import { reactive, ref, computed } from vue const examState reactive({ recordId: null, paper: {}, questions: [], answers: {}, currentIndex: 0, remainingSeconds: 0, }) const currentQuestion computed(() examState.questions[examState.currentIndex])答题区根据题型渲染不同的组件单选和判断题用radio多选题用checkbox简答题用textarea。每切换一道题先把当前题的答案写入answers对象然后立刻调用保存草稿接口。不要等到交卷才一次性把所有答案提交那样一旦中途断网或浏览器崩溃整个考试体验就毁了。我实现了一个500ms的防抖保存import { debounce } from lodash-es const saveDraft debounce(async () { await api.saveExamDraft(examState.recordId, examState.answers) }, 500)每次选项变化或text内容变化时调用saveDraft用户基本无感知。4.3 倒计时、自动保存与交卷拦截倒计时如果简单用setInterval每秒减1秒会有一个问题页面切到后台时浏览器会降级定时器回来时时间往往已经偏差。考生如果开了多个页面或调整系统时间本地倒计时就不可信了。正确的做法是以后端返回的截止时间戳为准。进入考试时后端返回一个endTimestamp前端每次tick都用Date.now()和endTimestamp相减const endTimestamp examState.endTimestamp const timer setInterval(() { const remain Math.max(0, endTimestamp - Date.now()) examState.remainingSeconds Math.floor(remain / 1000) if (remain 0) { clearInterval(timer) submitPaper(true) } }, 1000)这样即使用户修改本地时间倒计时也不会乱。刷新页面后重新拉取考试详情接口依然能拿到同一个endTimestamp剩余时间自动恢复。交卷前我先做二次确认弹出对话框提示“还有未作答的题目确定交卷吗”。这里要注意交互流程交卷请求发出后按钮要进入loading状态并禁用防止用户连续点击触发多次请求。后端虽然已经用原子更新做了防重复但前端依然要主动避免重复请求。5. 联调阶段我踩过的四个典型坑现象、定位与修复开发中踩坑是必然的关键在于能不能从现象快速定位到根因。这里挑四个给我印象最深的记录一下。5.1 跨域请求被CORS拦下第一个坑发生在前后端联调的起点。前端在localhost:5173启动后端在localhost:8000前端请求/user/login接口时浏览器控制台报错。网络请求其实已经发出去了后端也正常返回了数据但浏览器因为响应头里没有Access-Control-Allow-Origin直接阻止了前端读取。解决办法是安装django-cors-headers并在settings里配置白名单INSTALLED_APPS [ ... corsheaders, ] MIDDLEWARE [ corsheaders.middleware.CorsMiddleware, ... ] CORS_ALLOWED_ORIGINS [ http://localhost:5173, ]需要注意的是CORS中间件必须放在CommonMiddleware之前否则部分情况下会有重复Origin头的问题。如果只是在开发环境联调也可以直接设CORS_ALLOW_ALL_ORIGINS True但正式部署必须换成白名单。5.2 考试中刷新导致答题进度丢失初始版本中答题答案只保存在前端reactive对象里刷新后所有状态归零学生需要重新作答。这个问题在前端演示时被当场发现属于体验级事故。我最终的方案是“服务器端草稿箱”机制。考生进入考试后前端每切换题目或修改答案都调用一次保存草稿接口后端把答案写入ExamAnswer表。刷新页面后加载考试详情时读取已有的ExamAnswer记录回填到前端状态。也就是说答案始终以服务器端存储为准前端只是一个展示层。这样设计既解决了刷新丢答案的问题也从一定程度上防止了考生利用localStorage篡改答案——因为答案根本不在本地保存。5.3 重复点击交卷导致的重复判分联调测试时我快速点两下交卷按钮结果发现ExamRecord出现两条submit_time更新日志成绩被算了两次。后来查日志发现第一版交卷接口的逻辑是“先get查到记录再判断状态再更新”两个并发请求都通过了状态判断各自执行了一次判分。解决办法就是前面提到的原子更新updated ExamRecord.objects.filter( idrecord_id, userrequest.user, statusexamining ).update(statussubmitted)这个SQL层面的条件更新比“先查后改”安全得多。改完之后我再测试连续点击日志里始终只有一次判分执行。5.4 客户端时间不准导致剩余时间混乱测试时同事故意把电脑时间往后调了10分钟发现考试页面的倒计时瞬间减少10分钟。如果考生靠改本地时间拖延考试那就完全失控了。问题根源在于前端倒计时用的是客户端本地时间。修复方案就是上面的endTimestamp机制服务器返回考试结束的绝对时间戳前端所有计算都基于这个时间戳和Date.now()的差值。本地时间再怎么改页码上显示的剩余时间也不会被愚弄。如果更严格一些还可以在后端加一道提交校验提交时检查当前服务器时间是否已经超过endTimestamp超过则按迟到交卷处理并提交当前已保存的答案。这道兜底逻辑我也加在了交卷接口里。6. 部署上线前的最终检查与后续演进空间6.1 前后端分离项目的最低上线方案开发阶段跑通了部署又是另一套事情。前后端分离项目的经典部署方式很简单Vue项目构建出静态文件交给Nginx托管Django用Gunicorn或uwsgi跑在本地端口Nginx把 /api 前缀的请求反向代理到后端。我的Nginx关键配置server { listen 80; server_name exam.example.com; root /opt/exam/frontend/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /static/ { alias /opt/exam/backend/static/; } }try_files这行很重要它解决了Vue路由在history模式下刷新404的问题。如果没有它直接访问/exam/123这样的路径请求到Nginx时Nginx找不到对应文件会返回404加上try_files后会把请求回退到index.html再由Vue路由接管。生产环境的数据库我换成了MySQLDjango的settings里配置好了MySQL连接和时区。部署前还要执行collectstatic把Django静态文件收集到指定目录关闭DEBUG模式否则Admin后台样式会丢失。6.2 从考试系统可以延伸出的几个方向核心功能跑通之后这个系统的扩展空间其实很大。上线后我根据使用机构的反馈陆续预演了几个方向可以给读者参考。第一是智能错题本。判分之后系统已经有了大量做题数据按知识点聚合错误率后可以给每个考生生成薄弱知识点清单下次练习时优先出对应题型的题。第二是Excel批量导入题目。教师手上的题库很多是Excel表格开发一个解析导入模块能省下大量手工录入时间但Excel的格式校验、选项解析、题目去重要提前设计好。第三是切屏监控。对一些严肃考试可以在前端监听页面可见性变化和键盘事件把异常行为记录到考试日志里供教师考后查看。这个功能不作为评判依据只提供参考信息。第四是成绩统计分析页面。按班级、课程、题目三个维度做正确率报表这能直接提升系统对培训机构的价值让老师从“会出题”变成“懂学情”。我个人在收尾阶段的体会是这类系统第一版不要贪功能先把组卷、考试、自动判分、成绩查看这四件事做到稳定就足够撑起实际使用了。我们的第一版从需求确认到上线用了三周后面所有时间都花在修边界情况和细节体验上。如果你正在计划做类似项目建议也按这个节奏来先跑通主干再慢慢加亮点功能。

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

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

免费获取报价 →
↑