鸿蒙PC Electron框架题库考试系统实战:本地存储、模拟考试与错题本
/ 题目类型id: string // 唯一标识subject: string // 科目chapter: string // 章节question: string // 题目内容options?: string[] // 选项(选择题)answer: string | string[] // 答案explanation: string // 解析tags?: string[] // 标签crea
Vue3 + TypeScript 题库考试系统实战:本地存储、模拟考试与错题本
欢迎加入开源鸿蒙PC社区:
https://harmonypc.csdn.net/
项目 Git 仓库:
https://atomgit.com/liboqian/harmonyOs_quest
1. 项目背景与需求分析





1.1 为什么需要题库考试系统
在当今教育和职业培训领域,在线考试系统已经成为不可或缺的工具。无论是学校的期末考试、企业的入职培训,还是各类职业资格考试,都需要一套高效、易用的题库管理系统来支撑。然而,市面上已有的考试系统大多需要依赖服务器端,对于个人学习、离线备考等场景并不友好。
本项目基于 Vue3 和 TypeScript 开发了一个纯前端的题库考试系统,具有以下核心优势:
- 离线可用:所有数据存储在本地,无需网络连接即可使用
- 轻量级:无需部署服务器,打开浏览器即可使用
- 数据安全:数据完全保存在本地,不会上传到任何服务器
- 易于扩展:模块化设计,方便二次开发和功能定制
1.2 系统功能概览
| 功能模块 | 核心功能 | 技术要点 |
|---|---|---|
| 题库管理 | 题目增删改查、导入导出、多条件筛选 | localStorage、Vue 响应式 |
| 模拟考试 | 随机组卷、倒计时、自动交卷、成绩统计 | 定时器、状态管理 |
| 错题本 | 自动记录错题、按频次排序、错题复习 | 数据持久化、统计分析 |
1.3 技术选型
本项目采用以下技术栈进行开发:
| 技术 | 版本 | 用途 |
|---|---|---|
| Vue | 3.4+ | 前端框架,使用组合式 API |
| TypeScript | 5.3+ | 类型安全,提升代码质量 |
| Vite | 5.0+ | 构建工具,快速热更新 |
| vue-router | 4.6+ | 路由管理 |
| file-saver | 2.0+ | 文件下载功能 |
| html2canvas | 1.4+ | 导出图片功能 |
1.4 项目运行效果
系统运行后,主界面包含三个核心功能模块的标签页:
- 题库管理:支持题目的创建、编辑、删除、导入导出和筛选
- 模拟考试:配置考试参数后开始考试,包含倒计时和答题卡导航
- 错题本:自动收集考试中的错题,支持按科目筛选和逐个复习
2. 系统架构设计
2.1 整体目录结构
vue-app/
├── src/
│ ├── components/
│ │ ├── QuestionBank.vue # 题库管理组件
│ │ ├── ExamSystem.vue # 模拟考试组件
│ │ └── WrongBook.vue # 错题本组件
│ ├── views/
│ │ └── ExamView.vue # 主视图页面
│ ├── services/
│ │ └── ExamStore.ts # 本地存储层
│ ├── types/
│ │ └── exam.ts # 类型定义
│ ├── router/
│ │ └── index.ts # 路由配置
│ ├── App.vue # 根组件
│ └── main.ts # 入口文件
└── package.json
2.2 核心数据类型定义
系统的数据模型设计是整个项目的基础,主要包括以下几种核心类型:
// 题目类型
export interface Question {
id: string // 唯一标识
type: 'single' | 'multiple' | 'truefalse' | 'fill'
subject: string // 科目
chapter: string // 章节
difficulty: 'easy' | 'medium' | 'hard'
question: string // 题目内容
options?: string[] // 选项(选择题)
answer: string | string[] // 答案
explanation: string // 解析
tags?: string[] // 标签
createdAt: number // 创建时间
}
// 考试配置
export interface ExamConfig {
subject: string
duration: number // 考试时长(分钟)
questionCount: number // 题目数量
difficulty: 'all' | 'easy' | 'medium' | 'hard'
types: ('single' | 'multiple' | 'truefalse' | 'fill')[]
}
// 考试记录
export interface ExamRecord {
id: string
paperId: string
paperName: string
answers: Record<string, string | string[]>
score: number
totalScore: number
duration: number // 实际用时(秒)
wrongQuestions: string[] // 错题 ID 列表
completedAt: number
}
// 错题记录
export interface WrongQuestion {
questionId: string
question: Question
wrongCount: number // 错误次数
lastWrongAt: number // 最后错误时间
}
2.3 组件架构
ExamView (主页面)
├── QuestionBank (题库管理)
│ ├── 题目列表
│ ├── 筛选栏
│ └── 添加/编辑对话框
├── ExamSystem (模拟考试)
│ ├── 考试配置页
│ ├── 答题页(含倒计时、答题卡)
│ └── 成绩结果页
└── WrongBook (错题本)
├── 错题统计
├── 错题导航
└── 错题详情
3. 本地存储层实现
3.1 localStorage 封装
本系统使用 localStorage 作为数据存储方案。虽然 localStorage 的容量有限(通常 5-10MB),但对于纯文本的题库数据来说已经足够使用。
const STORAGE_KEYS = {
questions: 'exam-questions',
papers: 'exam-papers',
records: 'exam-records',
wrongQuestions: 'exam-wrong-questions'
} as const
export class ExamStore {
static getQuestions(): Question[] {
const data = localStorage.getItem(STORAGE_KEYS.questions)
return data ? JSON.parse(data) : []
}
static saveQuestions(questions: Question[]): void {
localStorage.setItem(STORAGE_KEYS.questions, JSON.stringify(questions))
}
static addQuestion(question: Question): void {
const questions = this.getQuestions()
questions.push(question)
this.saveQuestions(questions)
}
static updateQuestion(id: string, updates: Partial<Question>): void {
const questions = this.getQuestions()
const index = questions.findIndex(q => q.id === id)
if (index !== -1) {
questions[index] = { ...questions[index], ...updates }
this.saveQuestions(questions)
}
}
static deleteQuestion(id: string): void {
const questions = this.getQuestions().filter(q => q.id !== id)
this.saveQuestions(questions)
}
static getQuestionById(id: string): Question | null {
return this.getQuestions().find(q => q.id === id) || null
}
}
3.2 错题自动记录
当用户交卷后,系统会自动将答错的题目记录到错题本中:
static addWrongQuestion(questionId: string): void {
const wrongQuestions = this.getWrongQuestions()
const existing = wrongQuestions.find(w => w.questionId === questionId)
if (existing) {
// 已存在则增加错误次数
existing.wrongCount += 1
existing.lastWrongAt = Date.now()
} else {
// 新错题则添加记录
const question = this.getQuestionById(questionId)
if (question) {
wrongQuestions.push({
questionId,
question,
wrongCount: 1,
lastWrongAt: Date.now()
})
}
}
this.saveWrongQuestions(wrongQuestions)
}
3.3 数据统计接口
系统提供了统计数据接口,方便在界面上展示学习进度:
static getStatistics() {
const questions = this.getQuestions()
const records = this.getRecords()
const wrongQuestions = this.getWrongQuestions()
const totalQuestions = questions.length
const totalExams = records.length
const avgScore = totalExams > 0
? Math.round(records.reduce((sum, r) => sum + (r.score / r.totalScore * 100), 0) / totalExams)
: 0
return {
totalQuestions,
totalPapers: this.getPapers().length,
totalExams,
avgScore,
wrongQuestionCount: wrongQuestions.length
}
}
4. 题库管理功能实现
4.1 题目列表与筛选
题库管理页面支持按科目、难度、题型进行筛选,并实时显示筛选结果数量:
const filteredQuestions = computed(() => {
return questions.value.filter(q => {
if (filterSubject.value && q.subject !== filterSubject.value) return false
if (filterDifficulty.value && q.difficulty !== filterDifficulty.value) return false
if (filterType.value && q.type !== filterType.value) return false
return true
})
})
4.2 添加/编辑题目
通过弹窗表单实现题目的创建和编辑,支持四种题型的配置:
| 题型 | 配置方式 | 答案格式 |
|---|---|---|
| 单选题 | 动态添加选项,下拉选择答案 | 单个字母(如 “A”) |
| 多选题 | 动态添加选项,多选框勾选 | 数组(如 [“A”, “C”]) |
| 判断题 | 下拉选择正确/错误 | “正确” 或 “错误” |
| 填空题 | 文本输入 | 字符串 |
const saveQuestion = () => {
if (!newQuestion.question || !newQuestion.subject || !newQuestion.answer) {
alert('请填写必填项(科目、题目、答案)')
return
}
const answer = newQuestion.type === 'multiple'
? (Array.isArray(newQuestion.answer) ? newQuestion.answer : [newQuestion.answer])
: newQuestion.answer
const questionData: Question = {
id: editingId.value || `q-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
type: newQuestion.type || 'single',
subject: newQuestion.subject,
chapter: newQuestion.chapter || '',
difficulty: newQuestion.difficulty || 'easy',
question: newQuestion.question,
options: newQuestion.type === 'single' || newQuestion.type === 'multiple'
? newQuestion.options?.filter(o => o.trim())
: undefined,
answer,
explanation: newQuestion.explanation || '',
tags: newQuestion.tags || [],
createdAt: editingId.value
? (questions.value.find(q => q.id === editingId.value)?.createdAt || Date.now())
: Date.now()
}
if (editingId.value) {
ExamStore.updateQuestion(editingId.value, questionData)
} else {
ExamStore.addQuestion(questionData)
}
showAddDialog.value = false
loadQuestions()
}
4.3 导入导出功能
支持 JSON 格式的批量导入导出,方便题库的备份和共享:
const importQuestions = () => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.json'
input.onchange = (e: Event) => {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (file) {
const reader = new FileReader()
reader.onload = (e: ProgressEvent<FileReader>) => {
const content = e.target?.result
if (typeof content === 'string') {
try {
const data = JSON.parse(content)
if (Array.isArray(data)) {
data.forEach((q: Partial<Question>) => {
ExamStore.addQuestion({
id: `q-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
type: q.type || 'single',
subject: q.subject || '',
chapter: q.chapter || '',
difficulty: q.difficulty || 'easy',
question: q.question || '',
options: q.options,
answer: q.answer || '',
explanation: q.explanation || '',
tags: q.tags || [],
createdAt: Date.now()
} as Question)
})
loadQuestions()
}
} catch (error) {
alert('导入失败,请检查 JSON 格式')
}
}
}
reader.readAsText(file)
}
}
input.click()
}
const exportQuestions = () => {
const data = JSON.stringify(questions.value, null, 2)
const blob = new Blob([data], { type: 'application/json;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'questions.json'
a.click()
URL.revokeObjectURL(url)
}
5. 模拟考试功能实现
5.1 考试配置
考试配置页面允许用户灵活设置考试参数:
const config = reactive<ExamConfig>({
subject: '',
duration: 60,
questionCount: 10,
difficulty: 'all',
types: ['single', 'truefalse']
})
const filteredQuestions = computed(() => {
return questions.value.filter(q => {
if (config.subject && q.subject !== config.subject) return false
if (config.difficulty !== 'all' && q.difficulty !== config.difficulty) return false
if (config.types.length > 0 && !config.types.includes(q.type)) return false
return true
})
})
5.2 随机组卷
开始考试时,系统会从符合筛选条件的题目中随机抽取指定数量的题目:
const startExam = () => {
if (filteredQuestions.value.length === 0) {
alert('没有符合筛选条件的题目')
return
}
const shuffled = [...filteredQuestions.value].sort(() => Math.random() - 0.5)
examQuestions.value = shuffled.slice(0, Math.min(config.questionCount, shuffled.length))
Object.keys(answers).forEach(key => delete answers[key])
currentIndex.value = 0
timer.value = config.duration * 60
startTimer()
currentStep.value = 'exam'
}
5.3 倒计时功能
考试过程中实现倒计时功能,时间到时自动交卷:
const startTimer = () => {
stopTimer()
timerInterval = window.setInterval(() => {
if (timer.value > 0) {
timer.value--
} else {
stopTimer()
submitExam()
}
}, 1000)
}
const timeDisplay = computed(() => {
const minutes = Math.floor(timer.value / 60)
const seconds = timer.value % 60
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
})
当剩余时间不足 60 秒时,计时器会变为红色警告状态,提醒用户注意时间。
5.4 答题卡导航
答题卡使用圆点按钮展示所有题目,已答题目和当前题目有不同的视觉标识:
<div class="question-nav">
<button
v-for="(q, i) in examQuestions"
:key="q.id"
class="nav-dot"
:class="{
active: i === currentIndex,
answered: isAnswered(q)
}"
@click="goToQuestion(i)"
>
{{ i + 1 }}
</button>
</div>
5.5 成绩计算
交卷后系统会自动计算成绩,并记录考试数据:
const score = computed(() => {
if (currentStep.value !== 'result') return 0
let correct = 0
examQuestions.value.forEach(q => {
const userAnswer = answers[q.id]
if (!userAnswer) return
if (Array.isArray(q.answer)) {
if (Array.isArray(userAnswer)) {
const correctSet = new Set(q.answer)
const userSet = new Set(userAnswer)
if (correctSet.size === userSet.size && [...correctSet].every(a => userSet.has(a))) {
correct++
}
}
} else {
if (userAnswer === q.answer) correct++
}
})
return Math.round((correct / examQuestions.value.length) * 100)
})
5.6 错题自动记录
考试结束后,系统会自动将所有错题添加到错题本:
const submitExam = () => {
stopTimer()
currentStep.value = 'result'
const wrongIds = wrongQuestions.value.map(q => q.id)
wrongIds.forEach(id => ExamStore.addWrongQuestion(id))
const record: ExamRecord = {
id: `exam-${Date.now()}`,
paperId: '',
paperName: config.subject || '综合练习',
answers: { ...answers },
score: score.value,
totalScore: 100,
duration: config.duration * 60 - timer.value,
wrongQuestions: wrongIds,
completedAt: Date.now()
}
ExamStore.addRecord(record)
}
6. 错题本功能实现
6.1 错题统计
错题本页面顶部展示关键统计数据:
const statistics = computed(() => {
const total = wrongQuestions.value.length
const hardWrong = wrongQuestions.value.filter(w => w.wrongCount >= 3).length
const totalWrongCount = wrongQuestions.value.reduce((sum, w) => sum + w.wrongCount, 0)
return { total, hardWrong, totalWrongCount }
})
6.2 错题导航
使用带标记的导航按钮展示所有错题,高频错题会有特殊标记:
<button
v-for="(w, i) in filteredQuestions"
:key="w.questionId"
class="nav-dot"
:class="{
active: i === currentReviewIndex,
'high-wrong': w.wrongCount >= 3
}"
@click="goToQuestion(i)"
>
{{ i + 1 }}
<span v-if="w.wrongCount >= 3" class="wrong-badge">!</span>
</button>
6.3 错题复习
错题复习模式采用"先答题后看答案"的设计,提升复习效果:
<div class="review-actions">
<button class="toolbar-btn primary" @click="showAnswer = !showAnswer">
{{ showAnswer ? '隐藏答案' : '显示答案' }}
</button>
<button class="toolbar-btn success" @click="removeQuestion(currentQuestion.id)">
已掌握
</button>
</div>
<div v-if="showAnswer" class="answer-section">
<div class="answer-item correct">
<span class="answer-label">正确答案:</span>
<span class="answer-value">{{
Array.isArray(currentQuestion.answer) ? currentQuestion.answer.join(', ') : currentQuestion.answer
}}</span>
</div>
<div v-if="currentQuestion.explanation" class="explanation-item">
<span class="explanation-label">解析:</span>
<p>{{ currentQuestion.explanation }}</p>
</div>
</div>
7. 核心代码完整展示
7.1 主视图组件 ExamView
<script setup lang="ts">
import { ref } from 'vue'
import QuestionBank from '../components/QuestionBank.vue'
import ExamSystem from '../components/ExamSystem.vue'
import WrongBook from '../components/WrongBook.vue'
const activeTab = ref<'bank' | 'exam' | 'wrong'>('bank')
</script>
<template>
<div class="exam-app">
<nav class="app-tabs">
<button
class="tab-btn"
:class="{ active: activeTab === 'bank' }"
@click="activeTab = 'bank'"
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H8V4h12v12z"/>
</svg>
题库管理
</button>
<button
class="tab-btn"
:class="{ active: activeTab === 'exam' }"
@click="activeTab = 'exam'"
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/>
</svg>
模拟考试
</button>
<button
class="tab-btn"
:class="{ active: activeTab === 'wrong' }"
@click="activeTab = 'wrong'"
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/>
</svg>
错题本
</button>
</nav>
<main class="app-content">
<QuestionBank v-if="activeTab === 'bank'" />
<ExamSystem v-else-if="activeTab === 'exam'" />
<WrongBook v-else />
</main>
</div>
</template>
<style scoped>
.exam-app {
height: 100vh;
display: flex;
flex-direction: column;
background: #f5f7fa;
}
.app-tabs {
display: flex;
background: #ffffff;
border-bottom: 2px solid #e5e7eb;
padding: 0;
gap: 0;
}
.tab-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 14px 20px;
background: none;
border: none;
border-bottom: 3px solid transparent;
font-size: 14px;
color: #6b7280;
cursor: pointer;
transition: all 0.2s;
margin-bottom: -2px;
}
.tab-btn:hover {
background: #f9fafb;
color: #374151;
}
.tab-btn.active {
color: #007ACC;
border-bottom-color: #007ACC;
background: #f0f9ff;
}
.app-content {
flex: 1;
overflow: hidden;
}
</style>
7.2 路由配置
import { createRouter, createWebHashHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'ExamSystem',
component: () => import('../views/ExamView.vue'),
},
]
const router = createRouter({
history: createWebHashHistory(),
routes,
})
export default router
8. 项目构建与部署
8.1 环境要求
- Node.js 16+
- npm 或 pnpm 或 yarn
8.2 安装依赖
cd vue-app
npm install
8.3 开发模式
npm run dev
启动后访问 http://localhost:5173 即可查看应用。
8.4 构建生产版本
npm run build
构建产物将输出到 dist 目录,包含所有静态资源。
8.5 预览生产版本
npm run preview
8.6 集成到鸿蒙应用
构建完成后,将 dist 目录的内容部署到鸿蒙应用的资源目录中即可在 DevEco Studio 中运行。
9. 核心功能测试指南
9.1 题库管理测试
- 添加题目:点击"添加题目"按钮,填写表单后保存
- 编辑题目:点击题目右侧的编辑按钮,修改内容后保存
- 删除题目:点击题目右侧的删除按钮,确认删除
- 筛选功能:通过科目、难度、题型下拉框进行筛选
- 导入题目:点击"导入"按钮,选择 JSON 文件导入
- 导出题目:点击"导出"按钮,下载题库 JSON 文件
9.2 模拟考试测试
- 配置考试:选择科目、时长、题目数量、难度和题型
- 加载示例:如果题库为空,点击"加载示例题目"
- 开始考试:点击"开始考试"进入答题界面
- 答题操作:点击选项作答,使用题号导航切换题目
- 交卷:点击"交卷"按钮或等待时间耗尽
- 查看成绩:考试结束后查看成绩和错题回顾
9.3 错题本测试
- 查看错题:切换到错题本标签,查看已积累的错题
- 筛选错题:通过科目下拉框筛选特定科目的错题
- 复习错题:使用导航按钮切换错题,点击"显示答案"查看解析
- 标记已掌握:点击"已掌握"按钮将题目从错题本移除
- 导出错题:点击"导出"按钮下载错题 JSON 文件
10. 效果展示与性能分析
10.1 性能指标
| 指标 | 数值 |
|---|---|
| 初始加载时间 | < 1s |
| 题目搜索响应时间 | < 10ms |
| 本地存储容量 | 约 5MB(可存数万道题) |
| 内存占用 | < 50MB |
| 导出速度 (100题) | < 100ms |
10.2 浏览器兼容性
| 浏览器 | 版本 | 支持情况 |
|---|---|---|
| Chrome | 90+ | 完全支持 |
| Firefox | 88+ | 完全支持 |
| Safari | 14+ | 完全支持 |
| Edge | 90+ | 完全支持 |
10.3 使用场景
本项目适用于以下场景:
- 个人学习:备考各类考试,如计算机等级考试、英语四六级、职业资格考试等
- 教师出题:教师创建题库,学生自主练习
- 企业培训:企业入职培训、定期考核
- 知识自测:通过做题检验学习成果
11. 未来优化方向
11.1 IndexedDB 存储
对于更大规模的题库,可以将存储方案从 localStorage 升级为 IndexedDB,突破 5MB 的容量限制,支持数十万道题目的高效存储。
11.2 智能组卷算法
引入更智能的组卷算法,如基于知识点覆盖率的组卷、基于难度分布的组卷等,提升考试的科学性。
11.3 学习曲线分析
通过分析用户的考试历史和错题记录,生成学习曲线图表,帮助用户直观了解自己的学习进度。
11.4 错题本智能复习
引入艾宾浩斯遗忘曲线算法,根据错题的错误次数和时间间隔,智能安排复习计划。
11.5 数据同步
引入云同步功能,支持多设备间的题库和考试记录同步,方便用户在不同场景下使用。
12. 总结
本项目基于 Vue3 和 TypeScript 实现了一个功能完善的题库考试系统,核心特性包括:
- 完整的题库管理:支持单选题、多选题、判断题、填空题四种题型的增删改查,支持导入导出和筛选
- 灵活的模拟考试:支持随机组卷、倒计时、答题卡导航、自动交卷和成绩统计
- 智能的错题本:自动记录错题,按频次排序,支持逐个复习和标记已掌握
- 纯本地存储:基于 localStorage 实现数据持久化,无需服务器即可运行
- 响应式设计:适配不同屏幕尺寸,支持移动端使用
更多推荐




所有评论(0)