结对编程作业:简易在线考试系统的设计与实现
第七周课程作业:简易在线考试系统的设计与实现
>课程: 软件开发与创新课程设计
>作者学号: 2452222
>结对队友学号: 2452223
>日期: 2026年4月18日
一、需求分析
根据课程要求,本次结对编程作业需要开发一个支持客观题考试的在线系统。经过讨论,我们将系统功能划分为三大核心模块:
| 模块 | 功能说明 |
|---|---|
| 题目管理模块 | 支持单选题、多选题、判断题的增删改查(CRUD),仅限管理员角色操作 |
| 考生答题模块 | 学生登录后进入考试,支持按题型(单选/多选/判断)分页展示、限时答题(180秒)、答案实时保存 |
| 自动判分模块 | 交卷或超时后自动计算得分,生成可视化成绩报告,包含错题解析与正确选项对比 |
非功能性需求有:
- 采用了 C/S 架构,本地运行,数据以 JSON 文件持久化
- 界面使用 PySide6 构建,保证跨平台兼容性
- 代码结构清晰,职责分离(数据层、界面层分离)
二、系统架构设计
2.1 技术选型
- 开发语言: Python3.x
- GUI 框架: PySide6(Qt for Python)
- 数据存储: JSON文件(
users.json、questions.json) - 架构模式: 经典三层架构(数据访问层->业务逻辑层->表示层)
2.2 文件结构
ExamSystem/
|—— main.py #程序入口,初始化QApplication
|—— window.py #所有界面窗口类(登录、考试、成绩、管理)
|—— data_manager.py #数据访问对象(DAO),负责JSON读写
|—— data/
|—— users.json #用户数据(用户名、密码、角色)
|—— questions.json #题库数据(题目、选项、答案、解析)
2.3 核心类图
三、数据库(JSON)结构设计
3.1 用户表 users.json
[
{
"username": "admin",
"password": "123",
"role": "admin"
},
{
"username": "student",
"password": "123",
"role": "student"
}
]
3.2 题目表 questions.json
[
{
"id": 1,
"type": "single",
"question": "Python的作者是谁?",
"options": [
"A. Guido",
"B. Linus",
"C. Bill"
],
"answer": [
"A"
],
"analysis": "Guido van Rossum 是 Python 之父"
},
{
"id": 2,
"type": "multiple",
"question": "以下哪些是编程语言?",
"options": [
"A. Python",
"B. Java",
"C. HTML"
],
"answer": [
"A",
"B"
],
"analysis": "HTML不是编程语言"
},
{
"id": 3,
"type": "judge",
"question": "2+3=6",
"options": [
"A. 正确",
"B. 错误"
],
"answer": [
"B"
],
"analysis": "基础数学"
}
]
字段说明:
- type:枚举值 single(单选)、multiple(多选)、judge(判断)
- options:选项列表,判断题固定为 ["A. 正确", "B. 错误"]
- answer:答案数组,多选支持多个答案(如 ["A", "B"])
四、详细设计与核心代码
4.1 数据访问层(data_manager.py)
点击查看代码
import json
import os
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
class DataManager:
QUESTION_FILE = os.path.join(BASE_DIR, "data", "questions.json")
USER_FILE = os.path.join(BASE_DIR, "data", "users.json")
@staticmethod
def load_questions():
if not os.path.exists(DataManager.QUESTION_FILE):
return []
with open(DataManager.QUESTION_FILE, "r", encoding="utf-8") as f:
return json.load(f)
@staticmethod
def save_questions(questions):
with open(DataManager.QUESTION_FILE, "w", encoding="utf-8") as f:
json.dump(questions, f, ensure_ascii=False, indent=4)
@staticmethod
def validate_user(username, password):
users = []
if os.path.exists(DataManager.USER_FILE):
with open(DataManager.USER_FILE, "r", encoding="utf-8") as f:
users = json.load(f)
for u in users:
if u["username"] == username and u["password"] == password:
return u
return None
设计亮点:
- 使用 BASE_DIR 保证相对路径的健壮性,避免在不同工作目录下运行时找不到文件。
- validate_user 返回完整用户字典,便于上层根据 role 字段进行路由。
4.2 程序入口(main.py)
点击查看代码
import sys
from PySide6.QtWidgets import QApplication
from window import LoginWindow
if __name__ == "__main__":
app = QApplication(sys.argv)
window = LoginWindow()
window.resize(300, 200)
window.show()
sys.exit(app.exec())
4.3 表示层(window.py)
由于 window.py 代码过长,这里只展示各个核心部分代码,完整代码贴在了此文章最后第九部分~
4.3.1 登录窗口(LoginWindow)
点击查看代码
class LoginWindow(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("登录")
# 布局与样式初始化...
def handle_login(self):
username = self.input_user.text()
password = self.input_pwd.text()
user = DataManager.validate_user(username, password)
if not user:
QMessageBox.warning(self, "错误", "用户名或密码错误")
return
if user["role"] == "student":
self.enter_exam()
elif user["role"] == "admin":
self.enter_admin()
4.3.2 考试窗口(ExamWindow)
(1)题目分组与分页渲染
点击查看代码
def group_questions(self, questions):
groups = {"single": [], "multiple": [], "judge": []}
for q in questions:
groups[q["type"]].append(q)
return groups
(2)动态控件生成
点击查看代码
def render_section(self):
self.clear_layout()
section = self.sections[self.current_section_index]
# ...
for q in questions:
q_box = QGroupBox(q["question"])
vbox = QVBoxLayout()
widgets = []
for opt in q["options"]:
if section in ["single", "judge"]:
btn = QRadioButton(opt)
else:
btn = QCheckBox(opt)
vbox.addWidget(btn)
widgets.append(btn)
# ...
(3)答案实时缓存
点击查看代码
def save_answers(self):
for qid, widgets in self.current_widgets.items():
selected = [w.text()[0] for w in widgets if w.isChecked()]
self.answers[qid] = selected
(4)倒计时与自动交卷
点击查看代码
def update_time(self):
self.time_left -= 1
self.timer_label.setText(f"剩余时间:{self.time_left}s")
if self.time_left <= 0:
self.submit_exam()
4.3.3 成绩报告窗口(ResultWindow)
点击查看代码
def submit_exam(self):
self.save_answers()
self.timer.stop()
score = 0
wrong = []
for q in self.questions:
qid = q["id"]
correct = set(q["answer"])
user = set(self.answers.get(qid, []))
if correct == user:
score += 1
else:
wrong.append({"question": q, "user_answer": list(user)})
self.result = ResultWindow(score, len(self.questions), wrong)
self.result.show()
self.close()
判分逻辑说明:
- 使用 set 比较,无视答案顺序,保证多选题判分的严谨性。
- 错题解析区展示:题目原文、用户答案(红色)、正确答案(绿色)、文字解析。
4.3.4 管理后台(AdminWindow)
点击查看代码
def change_type(self, current_text):
if current_text == "判断题":
self.options_edit.setText("A. 正确\nB. 错误")
self.options_edit.setReadOnly(current_text == "判断题")
def reorder_question_ids(self):
for idx, q in enumerate(self.questions, start=1):
q["id"] = idx
五、运行结果与截图
5.1 登录界面
说明:输入用户名/密码后的登录界面,可分别登录学生账号和管理员账号。
5.2 学生考试界面
(1)单选题部分

说明:考试进行中的界面,可见倒计时、题型标题、单选按钮组。
(2)多选题部分

说明:多选题的复选框状态,证明分页功能正常。
(3)判断题部分

说明:判断题也是单选按钮组。
5.3 成绩报告界面
(1)成绩报告截图(包含错题解析)


说明:展示交卷后的结果页面,包含得分(如1/3)、错题卡片(红字用户答案、绿字正确答案、解析文本)。第二张图片是放大后的页面展示。
(2)满分通过截图

说明:可额外展示全部答对时的界面,显示"恭喜!你全对了!"。
5.4 管理员题目管理界面
(1)管理后台截图(包含题目列表与编辑区)

说明:左侧是题目列表,右侧是表单(题型下拉框、题目文本、选项、答案、解析),表单内容可进行编辑更改。
(2)新增题目操作截图
![]() |
![]() |
![]() |
说明:点击"新增"后跳出空表单,填写信息并点击保存,更新列表和表单。
(3)删除题目操作截图
![]() |
![]() |
说明:点击题目删除跳出确认,确认删除后更新列表
5.5 数据文件变化
questions.json内容变化

说明:新增题目之后,用VS Code或记事本打开JSON文件,可见保存的数据结构。
六、算法设计思路总结
-
数据驱动视图: 所有界面均基于
questions列表动态生成,而非硬编码控件。新增题型时只需扩展数据结构和渲染逻辑即可。 -
状态管理:
ExamWindow中通过self.answers字典维护答题状态,键为题目ID,值为选项字母列表,实现答案的跨页面持久化。 -
集合判分: 利用 Python
set的无序性和哈希比较,将时间复杂度降至 O(n),且代码简洁。 -
防御式编程: 文件不存在时返回空列表而非抛出异常;登录验证失败给予明确提示;删除操作前弹出确认对话框。
七、结对编程作业体会
本次实验采用结对编程模式完成,我们两人先共同讨论系统架构与界面原型,随后轮流担任编程手和审核员的角色。
7.1 角色分工与协作
-
第一阶段(架构设计): 共同分析需求,然后确定使用 PySide6 作为 GUI 框架,JSON 作为轻量级存储。画了简单的界面草图,明确了三大窗口的职责边界。
-
第二阶段(编码实现): 交替进行编码。一人负责敲代码时,另一人负责审查语法、思考补充。
-
第三阶段(测试与调优): 共同进行黑盒测试,分别用学生账号和管理员账号走完全部流程,发现了“判断题选项未锁定”、“多选题答案顺序导致误判”等问题并进行了修复。
7.2 结对编程的收获
-
代码质量显著提升: 实时 Code Review 使得低级错误在诞生之初就被消灭,减少了后期 Debug 的时间。
-
知识互补: 我们一人对 PySide6 信号槽机制更熟悉,另一人对 Python 文件操作和 JSON 处理更有经验,通过讲解加深了双方的理解。
-
设计思路更严谨: 在审核员的监督下,在写每一段代码前都要先说明"为什么要这样写",促使采用了更合理的
DataManager静态类设计和集合判分算法。 -
沟通与表达能力: 技术方案需要用语言向对方表达,锻炼了我们把抽象的思路具象化的能力,为这也是团队协作所不可或缺的重要部分。
7.3 存在的不足与改进方向
-
界面美观度: 目前仅使用基础样式表(QSS),后续可引入 Qt Designer 设计更现代的界面。
-
数据持久化: JSON 文件在并发写入时存在风险,后续可迁移至 SQLite 轻量级数据库。
-
功能扩展: 当前缺少“注册功能”和“考试历史记录”,后续可以新增这些功能。
八、总结
在这次的结对编程作业中,我们完成了一个功能完整的简易在线考试系统,更重要的是体验了敏捷开发中“两人一组、实时协作”的工作模式。从需求分析到架构设计,从编程到测试,这每一步都体现了团队合作的意义所在。
不得不承认结对编程让代码更健壮、思路更清晰,也让编程过程不再孤单,说到这我想到现在 OPC 的发展趋势让许多自许的“超级个体”容易走向极端化,他们在工作过程可能确实不再需要编程同伴是人类了,成天只是与AI打交道。但是我想那样结对编程的优点可能也会更加凸显吧,毕竟能说上话的真实存在的同伴,沟通起来比冰冷的机器更具有一份人情味~😄
九、window.py 完整代码
点击查看代码
from PySide6.QtWidgets import *
from PySide6.QtCore import Qt, QTimer
from data_manager import DataManager
class LoginWindow(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("登录")
layout = QVBoxLayout()
layout.setSpacing(15)
layout.setContentsMargins(40, 40, 40, 40)
title = QLabel("在线考试系统")
title.setStyleSheet("font-size: 20px; font-weight: bold;")
title.setAlignment(Qt.AlignCenter)
self.input_user = QLineEdit()
self.input_user.setPlaceholderText("用户名")
self.input_user.setFixedHeight(30)
self.input_pwd = QLineEdit()
self.input_pwd.setPlaceholderText("密码")
self.input_pwd.setEchoMode(QLineEdit.Password)
self.input_pwd.setFixedHeight(30)
self.btn_login = QPushButton("登录")
self.btn_login.setFixedHeight(35)
layout.addWidget(title)
layout.addWidget(self.input_user)
layout.addWidget(self.input_pwd)
layout.addWidget(self.btn_login)
self.setLayout(layout)
self.btn_login.clicked.connect(self.handle_login)
def handle_login(self):
username = self.input_user.text()
password = self.input_pwd.text()
user = DataManager.validate_user(username, password)
if not user:
QMessageBox.warning(self, "错误", "用户名或密码错误")
return
if user["role"] == "student":
self.enter_exam()
elif user["role"] == "admin":
self.enter_admin()
def enter_exam(self):
questions = DataManager.load_questions()
if not questions:
QMessageBox.warning(self, "错误", "题库为空")
return
self.exam = ExamWindow(questions)
self.exam.resize(280, 400)
self.exam.show()
self.close()
def enter_admin(self):
self.admin = AdminWindow()
self.admin.show()
self.close()
class ExamWindow(QWidget):
def __init__(self, questions):
super().__init__()
self.setWindowTitle("考试")
self.questions = questions
self.answers = {}
self.groups = self.group_questions(questions)
self.sections = ["single", "multiple", "judge"]
self.current_section_index = 0
self.time_left = 180
self.layout = QVBoxLayout()
self.timer_label = QLabel()
self.timer_label.setStyleSheet("color:red; font-size:16px;")
self.layout.addWidget(self.timer_label)
self.section_label = QLabel()
self.section_label.setStyleSheet("font-size:16px;")
self.layout.addWidget(self.section_label)
self.scroll = QScrollArea()
self.scroll_widget = QWidget()
self.scroll_layout = QVBoxLayout()
self.scroll_widget.setLayout(self.scroll_layout)
self.scroll.setWidget(self.scroll_widget)
self.scroll.setWidgetResizable(True)
self.layout.addWidget(self.scroll)
btn_layout = QVBoxLayout()
self.btn_next = QPushButton("下一部分")
self.btn_back = QPushButton("上一部分")
self.btn_submit = QPushButton("提交")
btn_layout.addWidget(self.btn_back)
btn_layout.addWidget(self.btn_next)
btn_layout.addWidget(self.btn_submit)
self.layout.addLayout(btn_layout)
self.setLayout(self.layout)
self.btn_back.clicked.connect(self.back_section)
self.btn_next.clicked.connect(self.next_section)
self.btn_submit.clicked.connect(self.submit_exam)
self.timer = QTimer()
self.timer.timeout.connect(self.update_time)
self.timer.start(1000)
self.render_section()
def group_questions(self, questions):
groups = {"single": [], "multiple": [], "judge": []}
for q in questions:
groups[q["type"]].append(q)
return groups
def update_time(self):
self.time_left -= 1
self.timer_label.setText(f"剩余时间:{self.time_left}s")
if self.time_left <= 0:
self.submit_exam()
def clear_layout(self):
while self.scroll_layout.count():
item = self.scroll_layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
def render_section(self):
self.clear_layout()
section = self.sections[self.current_section_index]
section_map = {
"single": "单选题",
"multiple": "多选题",
"judge": "判断题"
}
self.section_label.setText(f"当前部分:{section_map[section]}")
self.current_widgets = {}
questions = self.groups[section]
for q in questions:
q_box = QGroupBox(q["question"])
q_box = QGroupBox(q["question"])
vbox = QVBoxLayout()
widgets = []
for opt in q["options"]:
if section in ["single", "judge"]:
btn = QRadioButton(opt)
else:
btn = QCheckBox(opt)
vbox.addWidget(btn)
widgets.append(btn)
q_box.setLayout(vbox)
self.scroll_layout.addWidget(q_box)
self.current_widgets[q["id"]] = widgets
if q["id"] in self.answers:
for w in widgets:
if w.text()[0] in self.answers[q["id"]]:
w.setChecked(True)
self.btn_next.setEnabled(self.current_section_index < len(self.sections) - 1)
self.btn_back.setEnabled(self.current_section_index > 0)
def save_answers(self):
for qid, widgets in self.current_widgets.items():
selected = [w.text()[0] for w in widgets if w.isChecked()]
self.answers[qid] = selected
def next_section(self):
self.save_answers()
if self.current_section_index < len(self.sections) - 1:
self.current_section_index += 1
self.render_section()
def back_section(self):
self.save_answers()
if self.current_section_index > 0:
self.current_section_index -= 1
self.render_section()
def submit_exam(self):
self.save_answers()
self.timer.stop()
score = 0
wrong = []
for q in self.questions:
qid = q["id"]
correct = set(q["answer"])
user = set(self.answers.get(qid, []))
if correct == user:
score += 1
else:
wrong.append({
"question": q,
"user_answer": list(user)
})
self.result = ResultWindow(score, len(self.questions), wrong)
self.result.show()
self.close()
class ResultWindow(QWidget):
def __init__(self, score, total, wrong):
super().__init__()
self.setWindowTitle("成绩报告")
self.resize(400, 500)
main_layout = QVBoxLayout()
main_layout.setSpacing(15)
main_layout.setContentsMargins(20, 20, 20, 20)
title = QLabel("考试结果")
title.setAlignment(Qt.AlignCenter)
title.setStyleSheet("font-size:22px; font-weight:bold;")
main_layout.addWidget(title)
score_label = QLabel(f"{score} / {total}")
score_label.setAlignment(Qt.AlignCenter)
percent = score / total if total else 0
if percent >= 0.8:
color = "#4CAF50"
elif percent >= 0.6:
color = "#ff9800"
else:
color = "#f44336"
score_label.setStyleSheet(f"""
font-size:26px;
font-weight:bold;
color:{color};
""")
main_layout.addWidget(score_label)
line = QFrame()
line.setFrameShape(QFrame.HLine)
line.setStyleSheet("color:#ccc;")
main_layout.addWidget(line)
wrong_title = QLabel("错题解析")
wrong_title.setStyleSheet("font-size:16px; font-weight:bold;")
main_layout.addWidget(wrong_title)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
container = QWidget()
container_layout = QVBoxLayout()
container_layout.setSpacing(10)
if not wrong:
empty_label = QLabel("恭喜!你全对了!")
empty_label.setAlignment(Qt.AlignCenter)
empty_label.setStyleSheet("color:green; font-size:16px;")
container_layout.addWidget(empty_label)
else:
for w in wrong:
q = w["question"]
card = QFrame()
card.setStyleSheet("""
QFrame {
border: 1px solid #ddd;
border-radius: 8px;
padding: 10px;
}
""")
vbox = QVBoxLayout()
q_label = QLabel(f"题目:{q['question']}")
q_label.setWordWrap(True)
user_label = QLabel(f"你的答案:{','.join(w['user_answer']) if w['user_answer'] else '<未选择>'}")
user_label.setStyleSheet("color:#f44336;")
correct_label = QLabel(f"正确答案:{','.join(q['answer'])}")
correct_label.setStyleSheet("color:#4CAF50;")
analysis_label = QLabel(f"解析:{q['analysis']}")
analysis_label.setWordWrap(True)
vbox.addWidget(q_label)
vbox.addWidget(user_label)
vbox.addWidget(correct_label)
vbox.addWidget(analysis_label)
card.setLayout(vbox)
container_layout.addWidget(card)
container.setLayout(container_layout)
scroll.setWidget(container)
main_layout.addWidget(scroll)
btn_close = QPushButton("关闭")
btn_close.setFixedHeight(35)
btn_close.clicked.connect(self.close)
main_layout.addWidget(btn_close)
self.setLayout(main_layout)
class AdminWindow(QWidget):
TYPE_MAP_TO_CH = {
"single": "单选题",
"multiple": "多选题",
"judge": "判断题"
}
CH_MAP_TO_TYPE = {
"单选题": "single",
"多选题": "multiple",
"判断题": "judge",
}
def __init__(self):
super().__init__()
self.setWindowTitle("题目管理")
self.questions = DataManager.load_questions()
main_layout = QHBoxLayout()
self.list_widget = QListWidget()
self.list_widget.setMinimumWidth(250)
main_layout.addWidget(self.list_widget, 2)
form_layout = QVBoxLayout()
form_layout.setSpacing(10)
self.type_box = QComboBox()
self.type_box.addItems(self.TYPE_MAP_TO_CH.values())
self.question_edit = QLineEdit()
self.options_edit = QTextEdit()
self.answer_edit = QLineEdit()
self.analysis_edit = QTextEdit()
form_layout.addWidget(QLabel("题型"))
form_layout.addWidget(self.type_box)
form_layout.addWidget(QLabel("题目"))
form_layout.addWidget(self.question_edit)
form_layout.addWidget(QLabel("选项(每行一个,如 A.xxx)"))
self.question_edit.setFixedHeight(30)
form_layout.addWidget(self.options_edit)
form_layout.addWidget(QLabel("答案(如 A 或 A,B)"))
self.answer_edit.setFixedHeight(30)
form_layout.addWidget(self.answer_edit)
form_layout.addWidget(QLabel("解析"))
form_layout.addWidget(self.analysis_edit)
btn_layout = QHBoxLayout()
self.btn_add = QPushButton("新增")
self.btn_add.setFixedHeight(35)
btn_layout.addWidget(self.btn_add)
self.btn_save = QPushButton("保存")
self.btn_save.setFixedHeight(35)
btn_layout.addWidget(self.btn_save)
self.btn_delete = QPushButton("删除")
self.btn_delete.setFixedHeight(35)
btn_layout.addWidget(self.btn_delete)
form_layout.addLayout(btn_layout)
main_layout.addLayout(form_layout, 3)
title = QLabel("题目管理系统")
title.setStyleSheet("font-size:18px; font-weight:bold;")
form_layout.insertWidget(0, title)
self.setLayout(main_layout)
self.list_widget.currentRowChanged.connect(self.load_selected)
self.type_box.currentTextChanged.connect(self.change_type)
self.btn_add.clicked.connect(self.add_question)
self.btn_save.clicked.connect(self.save_question)
self.btn_delete.clicked.connect(self.delete_question)
self.refresh_list()
self.list_widget.setCurrentRow(0)
def refresh_list(self):
self.list_widget.clear()
for q in self.questions:
self.list_widget.addItem(f"{q['id']}. {q['question']}")
def reorder_question_ids(self):
for idx, q in enumerate(self.questions, start=1):
q["id"] = idx
def load_selected(self, index):
if index < 0:
return
q = self.questions[index]
self.type_box.setCurrentText(self.TYPE_MAP_TO_CH[q["type"]])
self.question_edit.setText(q["question"])
self.options_edit.setText("A. 正确\nB. 错误" if q["type"] == "judge" else "\n".join(q["options"]))
self.options_edit.setReadOnly(q["type"] == "judge")
self.answer_edit.setText(",".join(q["answer"]))
self.analysis_edit.setText(q["analysis"])
def change_type(self, current_text):
if current_text == "判断题":
self.options_edit.setText("A. 正确\nB. 错误")
self.options_edit.setReadOnly(current_text == "judge")
def add_question(self):
new_q = {
"id": len(self.questions) + 1,
"type": "single",
"question": "",
"options": [],
"answer": [],
"analysis": ""
}
self.questions.append(new_q)
self.refresh_list()
self.list_widget.setCurrentRow(len(self.questions) - 1)
def save_question(self):
index = self.list_widget.currentRow()
if index < 0:
return
q = self.questions[index]
q["type"] = self.CH_MAP_TO_TYPE[self.type_box.currentText()]
q["question"] = self.question_edit.text()
q["options"] = self.options_edit.toPlainText().split("\n")
q["answer"] = [a.strip() for a in self.answer_edit.text().split(",")]
q["analysis"] = self.analysis_edit.toPlainText()
self.reorder_question_ids()
DataManager.save_questions(self.questions)
self.refresh_list()
QMessageBox.information(self, "成功", "保存成功")
def delete_question(self):
index = self.list_widget.currentRow()
if index < 0:
return
reply = QMessageBox.question(
self, "确认", "确定删除该题?"
)
if reply == QMessageBox.Yes:
self.questions.pop(index)
self.reorder_question_ids()
DataManager.save_questions(self.questions)
self.refresh_list()





浙公网安备 33010602011771号