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 题库管理测试

  1. 添加题目:点击"添加题目"按钮,填写表单后保存
  2. 编辑题目:点击题目右侧的编辑按钮,修改内容后保存
  3. 删除题目:点击题目右侧的删除按钮,确认删除
  4. 筛选功能:通过科目、难度、题型下拉框进行筛选
  5. 导入题目:点击"导入"按钮,选择 JSON 文件导入
  6. 导出题目:点击"导出"按钮,下载题库 JSON 文件

9.2 模拟考试测试

  1. 配置考试:选择科目、时长、题目数量、难度和题型
  2. 加载示例:如果题库为空,点击"加载示例题目"
  3. 开始考试:点击"开始考试"进入答题界面
  4. 答题操作:点击选项作答,使用题号导航切换题目
  5. 交卷:点击"交卷"按钮或等待时间耗尽
  6. 查看成绩:考试结束后查看成绩和错题回顾

9.3 错题本测试

  1. 查看错题:切换到错题本标签,查看已积累的错题
  2. 筛选错题:通过科目下拉框筛选特定科目的错题
  3. 复习错题:使用导航按钮切换错题,点击"显示答案"查看解析
  4. 标记已掌握:点击"已掌握"按钮将题目从错题本移除
  5. 导出错题:点击"导出"按钮下载错题 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 实现数据持久化,无需服务器即可运行
  • 响应式设计:适配不同屏幕尺寸,支持移动端使用
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐