用 Uni-app 开发 C3 继续教育题库系统:静态资源导入、响应式交互与考试逻辑实现全解析

基于 Uni-app 开发的 C3 继续教育题库系统是一个功能完整的移动端学习应用,专注于提供高质量的在线练习和模拟考试体验。系统采用现代化的技术栈,具备良好的用户体验和稳定的性能表现。

一、核心功能模块

  1. 题库练习系统
    1)三种题型支持:单选题(400道)、多选题(200道)、判断题(400道)
    2)实时答题反馈,正确答案绿色标识,错误答案红色标识
    3)智能题型切换,一键切换不同类型题目
    4)自动分页浏览,支持上一题 / 下一题快速导航
    5)单选题和判断题答对自动跳题功能
  2. 模拟考试系统
    1)智能组卷算法:随机抽取 40 道单选题 + 20 道多选题 + 40 道判断题
    2)严格考试计时:90 分钟倒计时,时间到自动交卷
    3)实时答题卡:直观显示已答 / 未答题目状态
    4)智能评分系统:支持多选题少选部分得分(0.5 分)
    5)详细成绩分析:按题型统计得分,错题归类展示
  3. 数据管理系统
    1)静态 JSON 文件优化加载,提升应用启动速度
    2)全局数据共享,实现页面间数据无缝传递
    3)本地数据缓存,减少网络请求
    4)数据验证机制,确保题目数据完整性

二、技术实现亮点

1、前端技术栈
1)Uni-app 框架:实现一套代码多端运行
2)Vue.js 响应式数据绑定:确保界面与数据实时同步
3)Uni-ui 组件库:提供丰富的 UI 组件支持
4)原生 CSS3 动画:提升用户交互体验
2、核心技术特性
1)模块化架构设计,代码组织清晰
2)响应式布局适配,支持各种屏幕尺寸
3)智能状态管理,优化应用性能
4)完善的错误处理机制
5)优雅的加载状态和错误提示
3、用户体验优化
1)流畅的页面切换动画
2)直观的答题状态标识
3)智能交卷提醒,未答题目提示
4)详细的成绩分析报告
5)友好的操作反馈机制

三、系统优势

1、跨平台兼容性:基于 Uni-app 开发,可同时运行于 iOS、Android、H5 等多个平台
2、性能优化:静态资源本地加载,无需网络请求,响应速度快
3、用户体验:现代化 UI 设计,流畅的交互动画,直观的操作流程
4、功能完整:从练习到考试,从答题到成绩分析,覆盖完整学习周期
5、易于维护:模块化代码结构,清晰的逻辑分层,便于后续功能扩展
代码如下:

主页面
<template>
  <view class="container">
    <!-- 添加加载状态提示 -->
    <view v-if="loading" class="loading-container">
      <activity-indicator size="large" color="#007AFF" />
      <text class="loading-text">正在加载题目数据...</text>
    </view>

    <!-- 主按钮区域 -->
    <view v-else class="button-container">
      <button 
        class="practice-btn"
        :disabled="!dataReady"
        @tap="goPractice"
      >
        <text class="btn-text">进入题库练习</text>
        <text v-if="!dataReady" class="btn-tip">(题目加载中)</text>
      </button>
      
      <button 
        class="mock-btn"
        :disabled="!dataReady"
        @tap="startMockExam"
      >
        <text class="btn-text">开始模拟考试</text>
        <text v-if="!dataReady" class="btn-tip">(题目加载中)</text>
      </button>
    </view>

    <!-- 错误提示 -->
    <view v-if="error" class="error-container">
      <text class="error-text">题目加载失败,请检查数据文件</text>
      <button class="retry-btn" @tap="loadAllQuestions">
        <text>重新加载</text>
      </button>
    </view>
  </view>
</template>

<script>
// 方案一:使用import静态导入(推荐)
import singleData from '@/static/data/single.json'
import multiData from '@/static/data/multi.json'
import judgeData from '@/static/data/judge.json'

export default {
  data() {
    return {
      singleQuestions: [],
      multiQuestions: [],
      judgeQuestions: [],
      loading: true,
      error: false
    }
  },
  computed: {
    dataReady() {
      return (
        this.singleQuestions.length > 0 &&
        this.multiQuestions.length > 0 &&
        this.judgeQuestions.length > 0
      )
    }
  },
  onLoad() {
    this.loadAllQuestions()
  },
  methods: {
    // 修改后的加载方法
    loadAllQuestions() {
      this.loading = true
      this.error = false
      
      try {
        // 直接使用导入的数据
        this.singleQuestions = singleData
        this.multiQuestions = multiData
        this.judgeQuestions = judgeData
        
        console.log('题目加载完成', {
          single: this.singleQuestions.length,
          multi: this.multiQuestions.length,
          judge: this.judgeQuestions.length
        })
        
        // 存储到全局
        getApp().globalData.questions = {
          single: this.singleQuestions,
          multi: this.multiQuestions,
          judge: this.judgeQuestions
        }
        
      } catch (e) {
        console.error('加载题目失败:', e)
        this.error = true
      } finally {
        this.loading = false
      }
    },

    goPractice() {
      if (!this.dataReady) {
        uni.showToast({
          title: '题目尚未加载完成',
          icon: 'none'
        })
        return
      }
      
      uni.navigateTo({
        url: '/pages/practice/practice'
      })
    },

    startMockExam() {
      if (!this.dataReady) {
        uni.showToast({
          title: '题目尚未加载完成',
          icon: 'none'
        })
        return
      }

      // 随机组卷(30单选+20多选+10判断)
      const examQuestions = [
        ...this.getRandomQuestions(this.singleQuestions, 30),
        ...this.getRandomQuestions(this.multiQuestions, 20),
        ...this.getRandomQuestions(this.judgeQuestions, 10)
      ]

      // 存储到全局
      getApp().globalData.examQuestions = this.shuffleArray(examQuestions)
      
      uni.navigateTo({
        url: '/pages/mock/mock'
      })
    },
    
    // 随机选题
    getRandomQuestions(questions, count) {
      const shuffled = [...questions].sort(() => Math.random() - 0.5)
      return shuffled.slice(0, count)
    },
    
    // 打乱数组顺序
    shuffleArray(array) {
      const result = [...array]
      for (let i = result.length - 1; i > 0; i--) {
        const j = Math.floor(Math.random() * (i + 1))
        ;[result[i], result[j]] = [result[j], result[i]]
      }
      return result
    }
  }
}
</script>

<style>
.container {
  padding: 40rpx;
  height: 100vh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  background-color: #f8f8f8;
}

.loading-container {
  flex: 1;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
}

.loading-text {
  margin-top: 30rpx;
  font-size: 28rpx;
  color: #666;
}

.button-container {
  width: 100%;
  display: flex;
  flex-direction: column;
  gap: 40rpx;
}

button {
  height: 120rpx;
  border-radius: 16rpx;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  font-size: 32rpx;
  color: white;
  box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.1);
}

button[disabled] {
  opacity: 0.6;
}

.practice-btn {
  background: linear-gradient(135deg, #007AFF, #00a8ff);
}

.mock-btn {
  background: linear-gradient(135deg, #FF9500, #FFB700);
}

.btn-text {
  font-weight: bold;
  font-size: 36rpx;
}

.btn-tip {
  font-size: 24rpx;
  opacity: 0.8;
  margin-top: 8rpx;
}

.error-container {
  margin-top: 60rpx;
  display: flex;
  flex-direction: column;
  align-items: center;
}

.error-text {
  color: #FF4D4F;
  font-size: 28rpx;
  margin-bottom: 30rpx;
  text-align: center;
}

.retry-btn {
  padding: 20rpx 40rpx;
  background-color: #007AFF;
  color: white;
  border-radius: 10rpx;
}
</style>
练习题页面
<template>
  <view class="container">
    <!-- 顶部题目类型标识 -->
    <view class="question-type">
      <text>
        {{currentType === 'single' ? '单选题' : currentType === 'multi' ? '多选题' : '判断题'}}
      </text>
    </view>
    
    <!-- 题型切换按钮 -->
    <view class="type-switch">
      <button 
        class="type-btn" 
        :class="{active: currentType === 'single'}"
        @click="switchType('single')"
      >
        单选题
      </button>
      <button 
        class="type-btn" 
        :class="{active: currentType === 'multi'}"
        @click="switchType('multi')"
      >
        多选题
      </button>
      <button 
        class="type-btn" 
        :class="{active: currentType === 'judge'}"
        @click="switchType('judge')"
      >
        判断题
      </button>
    </view>
           
    <!-- 当前题目 -->
    <view v-if="currentQuestion" class="question-box">
      <text class="q-title">{{currentPage}}. {{currentQuestion.question}}</text>
      
      <!-- 多选题提示 -->
      <view v-if="currentType === 'multi'" class="multi-tip">
        <text>(多选题,请选择所有正确答案)</text>
      </view>
      
      <view class="options-container">
        <view 
          v-for="(opt, i) in currentQuestion.options" 
          :key="i" 
          class="option"
          :style="{ backgroundColor: getOptionColor(currentQuestion, i) }"
          @click="choose(currentQuestion, i)"
        >
          <!-- 多选题显示正确对号和错误叉号 -->
          <view v-if="currentType === 'multi' && currentQuestion.selected.includes(i)" class="checkmark">
            {{ currentQuestion.answer.includes(i) ? '✓' : '✗' }}
          </view>

          <!-- 单选题显示选择对错标记 -->
          <view v-if="currentType === 'single' && currentQuestion.selected !== -1 && currentQuestion.selected === i" class="checkmark">
            {{ currentQuestion.answer.includes(i) ? '✓' : '✗' }}
          </view>

          <text class="option-text">{{getOptionPrefix(i)}} {{opt}}</text>
        </view>
      </view>
    </view>
    
    <!-- 分页控制 -->
    <view class="pagination">
      <button @click="prevPage" :disabled="currentPage === 1">上一题</button>
      <text>{{currentPage}}/{{questions.length}}</text>
      <button @click="nextPage" :disabled="currentPage === questions.length">下一题</button>
    </view>
  </view>
</template>

<script>
// 直接导入 JSON 数据,打包后可用
import singleData from '@/static/data/single.json';
import multiData from '@/static/data/multi.json';
import judgeData from '@/static/data/judge.json';

export default {
  data() {
    return {
      questions: [],
      currentPage: 1,
      autoNextTimer: null,
      currentType: 'single',
      loading: false
    }
  },
  computed: {
    currentQuestion() {
      return this.questions[this.currentPage - 1];
    }
  },
  onLoad() {
    this.loadQuestions('single');
  },
  methods: {
    loadQuestions(type) {
      this.loading = true;
      this.currentType = type;
      this.currentPage = 1;
      let data = [];
      switch(type) {
        case 'single': data = singleData; break;
        case 'multi': data = multiData; break;
        case 'judge': data = judgeData; break;
      }
      
      // 格式化题目
      this.questions = data.map(q => {
        if (type === 'judge') {
          return {
            ...q,
            options: ['正确','错误'],
            selected: -1,
            answer: Array.isArray(q.answer) ? q.answer : [q.answer]
          };
        } else if (type === 'multi') {
          return {...q, selected: []};
        } else { // 单选
          return {...q, selected: -1, answer: Array.isArray(q.answer) ? q.answer : [q.answer]};
        }
      });
      this.loading = false;
    },

    getOptionPrefix(i) {
      if (this.currentType === 'judge') return '';
      return String.fromCharCode(65 + i) + '.';
    },

    switchType(type) {
      if (this.currentType !== type) this.loadQuestions(type);
    },

    choose(q, i) {
      if (this.currentType === 'multi') {
        const idx = q.selected.indexOf(i);
        if (idx === -1) q.selected.push(i);
        else q.selected.splice(idx,1);
      } else {
        if (q.selected !== -1) return;
        q.selected = i;

        // 单选题:选择正确自动跳下一题
        if (this.currentType === 'single' && q.answer.includes(i)) {
          this.setAutoNext();
        }

        // 判断题:0表示正确选项,自动跳下一题
        if (this.currentType === 'judge') {
          if (q.answer.includes(i) && i===0) this.setAutoNext();
        }
      }
    },

    getOptionColor(q,i) {
      if (this.currentType === 'multi') {
        if (q.selected.includes(i)) return q.answer.includes(i) ? '#4CAF50' : '#F44336';
        return '#f5f5f5';
      } else if (this.currentType === 'single') {
        if (q.selected === -1) return '#f5f5f5';
        if (q.answer.includes(i)) return '#4CAF50';
        if (q.selected === i && !q.answer.includes(i)) return '#F44336';
        return '#f5f5f5';
      } else { // 判断题
        if (q.selected === -1) return '#f5f5f5';
        if (q.answer.includes(i)) return '#4CAF50';
        if (q.selected===i && !q.answer.includes(i)) return '#F44336';
        return '#f5f5f5';
      }
    },

    prevPage() {
      this.clearAutoNext();
      if (this.currentPage > 1) this.currentPage--;
    },
    nextPage() {
      this.clearAutoNext();
      if (this.currentPage < this.questions.length) this.currentPage++;
    },
    setAutoNext() {
      this.clearAutoNext();
      this.autoNextTimer = setTimeout(()=>{
        if(this.currentPage < this.questions.length) this.currentPage++;
      },1000);
    },
    clearAutoNext() {
      if(this.autoNextTimer) {
        clearTimeout(this.autoNextTimer);
        this.autoNextTimer=null;
      }
    }
  },
  beforeDestroy() {
    this.clearAutoNext();
  }
}
</script>

<style>
.container {padding:20rpx; display:flex; flex-direction:column; height:100vh; color:#000;}
.question-type {padding:10rpx 20rpx; background-color:#2196F3; color:#fff; border-radius:20rpx; align-self:flex-start; margin-bottom:20rpx; font-size:28rpx; font-weight:bold;}
.type-switch {display:flex; justify-content:space-between; margin-bottom:20rpx;}
.type-btn {flex:1; margin:0 10rpx; padding:15rpx; font-size:28rpx; border-radius:8rpx; background-color:#f0f0f0; color:#000;}
.type-btn.active {background-color:#2196F3; color:#fff;}
.question-box {flex:1; margin:20rpx 0; padding:30rpx; border:1px solid #eee; border-radius:15rpx; background-color:#fff; display:flex; flex-direction:column;}
.multi-tip {padding:10rpx 0; color:#666; font-size:26rpx; margin-bottom:20rpx;}
.q-title {font-weight:bold; margin-bottom:20rpx; font-size:36rpx; line-height:1.6; color:#000;}
.options-container {flex:1; display:flex; flex-direction:column; justify-content:space-around;}
.option {width:100%; padding:30rpx; margin:15rpx 0; border-radius:12rpx; color:#000; box-sizing:border-box; font-size:32rpx; transition:all 0.3s; display:flex; align-items:center; min-height:120rpx; position:relative;}
.checkmark {position:absolute; left:20rpx; color:#fff; font-weight:bold; font-size:28rpx;}
.option-text {width:100%; word-break:break-word; margin-left:40rpx;}
.option:active {opacity:0.8; transform:scale(0.98);}
.pagination {display:flex; justify-content:space-between; align-items:center; padding:20rpx 0; margin:20rpx 0;}
button {padding:15rpx 40rpx; font-size:32rpx; border-radius:8rpx; background-color:#f0f0f0; color:#000;}
button:disabled {opacity:0.5;}
text {color:#000 !important;}
</style>
模拟考试页面
<template>
  <view class="exam-container">
    <!-- 考试进行中视图 -->
    <view v-if="!examFinished">
      <!-- 考试头部信息 -->
      <view class="exam-header">
        <text class="exam-title">模拟考试</text>
        <view class="exam-info">
          <text class="question-type">{{getQuestionType(currentQuestion.type)}}</text>
          <text>题号: {{currentIndex + 1}}/{{questions.length}}</text>
          <text class="timer" :class="{ 'warning': remainingTime < 600 }">时间: {{formatTime(remainingTime)}}</text>
        </view>
      </view>

      <!-- 考试题目 -->
      <view class="question-container">
        <view class="question-box">
          <!-- 修复:确保题目为空时不报错 -->
          <text class="q-title" v-if="currentQuestion.id">{{currentQuestion.id}}. {{currentQuestion.question}}</text>
          <text class="q-title" v-else>加载题目中...</text>
          
          <!-- 选项容器:修复选中背景不显示问题 -->
          <view class="options-container" v-if="currentQuestion.options">
            <view 
              v-for="(opt, i) in currentQuestion.options" 
              :key="i"
              class="option"
              :class="{
                'selected': isOptionSelected(i), // 核心:确保选中状态生效
                'correct': showAnswer && currentQuestion.answer.includes(i),
                'wrong': showAnswer && isOptionSelected(i) && !currentQuestion.answer.includes(i)
              }"
              @click="handleSelectOption(i)"
              :disabled="examFinished"  
            >
              <text class="option-text">{{getOptionPrefix(i)}}. {{opt}}</text>
            </view>
          </view>
          <view v-else class="loading-options">选项加载中...</view>
        </view>
      </view>

      <!-- 考试控制按钮 -->
      <view class="exam-controls">
        <button 
          class="control-btn prev-btn"
          :disabled="currentIndex === 0 || examFinished"
          @click="prevQuestion"
        >
          上一题
        </button>
        <button 
          class="control-btn next-btn"
          :disabled="currentIndex === questions.length - 1 || examFinished"
          @click="nextQuestion"
        >
          下一题
        </button>
        <button 
          class="control-btn submit-btn"
          :disabled="examFinished" 
          @click="confirmSubmit"
        >
          提交试卷
        </button>
      </view>

      <!-- 答题卡:显示已答/未答状态 -->
      <view class="answer-sheet">
        <text class="sheet-title">答题卡 ({{answeredCount}}/{{questions.length}})</text>
        <view class="sheet-questions">
          <view 
            v-for="(q, index) in questions"
            :key="q.id"
            class="sheet-item"
            :class="{
              'answered': isQuestionAnswered(q), 
              'unanswered': !isQuestionAnswered(q), 
              'current': index === currentIndex
            }"
            @click="jumpToQuestion(index)"
            :disabled="examFinished"
          >
            {{index + 1}}
          </view>
        </view>
      </view>
    </view>

    <!-- 考试结果详情视图 -->
    <view v-if="examFinished && showResultDetail">
      <view class="result-header">
        <text class="result-title">考试结果</text>
        <view class="result-summary">
          <text class="score">得分: {{score.toFixed(1)}}/100.0</text>
          <text class="evaluation">{{evaluation}}</text>
        </view>
        <button class="back-btn" @click="exitExam">返回</button>
      </view>

      <view class="result-detail">
        <view class="filter-controls">
          <button 
            class="filter-btn" 
            :class="{ 'active': filterType === 'all' }" 
            @click="filterType = 'all'"
          >
            全部题目
          </button>
          <button 
            class="filter-btn" 
            :class="{ 'active': filterType === 'correct' }" 
            @click="filterType = 'correct'"
          >
            做对 ({{correctCount}})
          </button>
          <button 
            class="filter-btn" 
            :class="{ 'active': filterType === 'wrong' }" 
            @click="filterType = 'wrong'"
          >
            做错/少选 ({{wrongOrPartialCount}})
          </button>
          <button 
            class="filter-btn" 
            :class="{ 'active': filterType === 'unanswered' }" 
            @click="filterType = 'unanswered'"
          >
            未答 ({{unansweredCount}})
          </button>
        </view>

        <view class="questions-detail">
          <view 
            v-for="(q, index) in filteredQuestions" 
            :key="q.id"
            class="question-detail-item"
            :class="{ 
              'correct': isQuestionCorrect(q), 
              'wrong': isQuestionWrong(q),
              'partial': isQuestionPartial(q),
              'unanswered': !isQuestionAnswered(q)
            }"
          >
            <view class="question-header">
              <text class="question-num">{{index + 1}}. {{getQuestionType(q.type)}}</text>
              <text class="question-status">
                {{!isQuestionAnswered(q) ? '未答' : (isQuestionCorrect(q) ? '全对' : (isQuestionPartial(q) ? '少选' : '错误'))}}
              </text>
            </view>
            <view class="question-content">{{q.question}}</view>
            
            <!-- 显示每题得分(修复:少选显示0.5分) -->
            <view class="question-score" v-if="isQuestionAnswered(q)">
              本题得分: {{getQuestionScore(q)}}/{{getQuestionFullScore(q)}}
            </view>
            <view class="question-score" v-else>
              本题得分: 0/{{getQuestionFullScore(q)}}(未答)
            </view>
            
            <view class="options-detail">
              <view 
                v-for="(opt, i) in q.options" 
                :key="i"
                class="detail-option"
                :class="{
                  'user-selected': isUserSelected(q, i), 
                  'correct-answer': q.answer.includes(i),
                  'wrong-selected': isUserSelected(q, i) && !q.answer.includes(i)
                }"
              >
                <text class="option-prefix">{{getOptionPrefix(i)}}. </text>
                <text class="option-content">{{opt}}</text>
                <text v-if="q.answer.includes(i)" class="correct-mark">✓ 正确答案</text>
                <text v-if="isUserSelected(q, i) && !q.answer.includes(i)" class="wrong-mark">✗ 你的答案</text>
                <text v-if="isUserSelected(q, i) && q.answer.includes(i)" class="your-answer-mark">✓ 你的答案</text>
              </view>
            </view>
          </view>
        </view>
      </view>
    </view>

    <!-- 提交确认弹窗 -->
    <uni-popup ref="popup" type="dialog" :mask="true">
      <uni-popup-dialog 
        type="info"
        title="确认提交"
        content="确定要提交试卷吗?提交后无法修改答案!"
        @confirm="submitExam"
        @close="closePopup"
      ></uni-popup-dialog>
    </uni-popup>

    <!-- 考试结果弹窗 -->
    <uni-popup ref="resultPopup" type="dialog" :mask="true">
      <uni-popup-dialog 
        type="info"
        title="考试完成!"
        :content="`本次考试得分:${score.toFixed(1)}/100.0\n${evaluation}\n\n是否查看详细答题情况?`"
        confirmText="查看详情"
        cancelText="直接返回"
        @confirm="handleShowDetail" 
        @cancel="exitExam"
      ></uni-popup-dialog>
    </uni-popup>
  </view>
</template>

<script>
import singleData from '@/static/data/single.json';
import multiData from '@/static/data/multi.json';
import judgeData from '@/static/data/judge.json';
import uniPopup from '@dcloudio/uni-ui/lib/uni-popup/uni-popup.vue';
import uniPopupDialog from '@dcloudio/uni-ui/lib/uni-popup-dialog/uni-popup-dialog.vue';

export default {
  components: { uniPopup, uniPopupDialog },
  data() {
    return {
      questions: [],
      currentIndex: 0,
      remainingTime: 5400,
      timer: null,
      showAnswer: false,
      examFinished: false,
      showResultDetail: false,
      score: 0,
      evaluation: '',
      filterType: 'all'
    }
  },
  computed: {
    currentQuestion() {
      // 修复:确保currentIndex合法时才返回题目,避免空对象
      if (this.questions.length && this.currentIndex >= 0 && this.currentIndex < this.questions.length) {
        return this.questions[this.currentIndex];
      }
      return { id: '', question: '', options: [], answer: [], type: '' };
    },
    answeredCount() {
      return this.questions.filter(q => this.isQuestionAnswered(q)).length;
    },
    unansweredCount() {
      return this.questions.filter(q => !this.isQuestionAnswered(q)).length;
    },
    correctCount() {
      return this.questions.filter(q => this.isQuestionAnswered(q) && this.isQuestionCorrect(q)).length;
    },
    wrongOrPartialCount() {
      return this.questions.filter(q => 
        this.isQuestionAnswered(q) && (!this.isQuestionCorrect(q) || this.isQuestionPartial(q))
      ).length;
    },
    filteredQuestions() {
      switch(this.filterType) {
        case 'all':
          return this.questions;
        case 'correct':
          return this.questions.filter(q => this.isQuestionAnswered(q) && this.isQuestionCorrect(q));
        case 'wrong':
          return this.questions.filter(q => 
            this.isQuestionAnswered(q) && (!this.isQuestionCorrect(q) || this.isQuestionPartial(q))
          );
        case 'unanswered':
          return this.questions.filter(q => !this.isQuestionAnswered(q));
        default:
          return this.questions;
      }
    }
  },
  onLoad() {
    this.initExam();
    this.startTimer();
  },
  onUnload() {
    this.stopTimer();
  },
  methods: {
    initExam() {
      try {
        // 修复:确保JSON数据格式正确,避免空数据
        const validSingle = singleData.filter(q => q.id && q.question && q.options && q.answer);
        const validMulti = multiData.filter(q => q.id && q.question && q.options && q.answer);
        const validJudge = judgeData.filter(q => q.id && q.question && q.options && q.answer);

        const singleQuestions = this.getRandomQuestions(validSingle, 40).map(q => ({
          ...q,
          type: 'single',
          selected: -1 // 单选初始未选:-1
        }));
        const multiQuestions = this.getRandomQuestions(validMulti, 20).map(q => ({
          ...q,
          type: 'multi',
          selected: [] // 多选初始未选:空数组
        }));
        const judgeQuestions = this.getRandomQuestions(validJudge, 40).map(q => ({
          ...q,
          type: 'judge',
          selected: -1 // 判断初始未选:-1
        }));

        this.questions = [...singleQuestions, ...multiQuestions, ...judgeQuestions];
      } catch (error) {
        console.error('初始化考试失败:', error);
        uni.showToast({ title: '加载题目失败', icon: 'none' });
        setTimeout(() => { uni.navigateBack(); }, 1500);
      }
    },

    getRandomQuestions(questions, count) {
      const shuffled = [...questions].sort(() => Math.random() - 0.5);
      return shuffled.slice(0, count);
    },

    startTimer() {
      this.timer = setInterval(() => {
        if (this.remainingTime > 0) {
          this.remainingTime--;
        } else {
          this.stopTimer();
          this.autoSubmitExam();
        }
      }, 1000);
    },
    stopTimer() {
      if (this.timer) {
        clearInterval(this.timer);
        this.timer = null;
      }
    },
    formatTime(seconds) {
      const mins = Math.floor(seconds / 60);
      const secs = seconds % 60;
      return `${mins.toString().padStart(2,'0')}:${secs.toString().padStart(2,'0')}`;
    },

    getQuestionType(type) {
      const types = { single:'单选题(1分)', multi:'多选题(2分)', judge:'判断题(0.5分)' };
      return types[type] || '未知题型';
    },
    getOptionPrefix(index) {
      return String.fromCharCode(65 + index);
    },

    // 修复:选中状态判断,确保单选/多选都能正确识别
    isOptionSelected(index) {
      const question = this.currentQuestion;
      if (!question || question.selected === undefined) return false;
      
      if (question.type === 'multi') {
        // 多选:判断索引是否在selected数组中
        return Array.isArray(question.selected) && question.selected.includes(index);
      } else {
        // 单选/判断:判断selected是否等于索引(排除-1未选状态)
        return question.selected !== -1 && question.selected === index;
      }
    },

    isQuestionAnswered(question) {
      if (!question || question.selected === undefined) return false;
      
      if (question.type === 'multi') {
        return Array.isArray(question.selected) && question.selected.length > 0;
      } else {
        return question.selected !== -1;
      }
    },

    isQuestionCorrect(question) {
      if (!this.isQuestionAnswered(question)) return false;
      
      if (question.type === 'multi') {
        return question.selected.length === question.answer.length &&
               question.selected.every(opt => question.answer.includes(opt));
      } else {
        return question.answer.includes(question.selected);
      }
    },

    isQuestionPartial(question) {
      if (question.type !== 'multi' || !this.isQuestionAnswered(question)) return false;
      
      // 少选条件:无错选 + 未选全
      const noWrongSelection = question.selected.every(opt => question.answer.includes(opt));
      const notFullSelection = question.selected.length < question.answer.length;
      return noWrongSelection && notFullSelection;
    },

    isQuestionWrong(question) {
      if (!this.isQuestionAnswered(question)) return false;
      
      if (question.type === 'multi') {
        // 多选错误:有错选
        return question.selected.some(opt => !question.answer.includes(opt));
      } else {
        // 单选/判断错误:选中≠正确答案
        return !question.answer.includes(question.selected);
      }
    },

    // 核心修改:多选题少选得分从1分改为0.5分
    getQuestionScore(question) {
      if (!this.isQuestionAnswered(question)) return 0;
      
      switch(question.type) {
        case 'single':
          return this.isQuestionCorrect(question) ? 1 : 0;
        case 'multi':
          if (this.isQuestionCorrect(question)) return 2; // 全对:2分
          if (this.isQuestionPartial(question)) return 0.5; // 少选:0.5分(原1分)
          return 0; // 错选:0分
        case 'judge':
          return this.isQuestionCorrect(question) ? 0.5 : 0;
        default:
          return 0;
      }
    },

    getQuestionFullScore(question) {
      switch(question.type) {
        case 'single': return 1;
        case 'multi': return 2;
        case 'judge': return 0.5;
        default: return 0;
      }
    },

    isUserSelected(question, index) {
      if (!question || question.selected === undefined) return false;
      
      if (question.type === 'multi') {
        return Array.isArray(question.selected) && question.selected.includes(index);
      } else {
        return question.selected !== -1 && question.selected === index;
      }
    },

    // 修复:选项选择逻辑,确保响应式更新生效
    handleSelectOption(index) {
      if (this.examFinished) return;
      const question = this.currentQuestion;
      if (!question) return;

      // 用$set确保Vue能监听到selected的变化(响应式核心)
      if (question.type === 'multi') {
        const selected = [...(question.selected || [])];
        const idx = selected.indexOf(index);
        if (idx === -1) {
          selected.push(index);
        } else {
          selected.splice(idx, 1);
        }
        this.$set(this.questions[this.currentIndex], 'selected', selected);
      } else {
        this.$set(this.questions[this.currentIndex], 'selected', index);
      }
    },

    prevQuestion() {
      if (this.currentIndex > 0 && !this.examFinished) {
        this.currentIndex--;
      }
    },
    nextQuestion() {
      if (this.currentIndex < this.questions.length - 1 && !this.examFinished) {
        this.currentIndex++;
      }
    },
    jumpToQuestion(index) {
      if (!this.examFinished && index >= 0 && index < this.questions.length) {
        this.currentIndex = index;
      }
    },

    confirmSubmit() {
      if (this.examFinished) return;
      if (this.unansweredCount > 0) {
        uni.showModal({
          title: '提示',
          content: `仍有${this.unansweredCount}道题未答,确定要提交吗?`,
          success: (res) => {
            if (res.confirm) {
              this.$refs.popup.open();
            }
          }
        });
      } else {
        this.$refs.popup.open();
      }
    },
    closePopup() {
      this.$refs.popup.close();
    },

    autoSubmitExam() {
      uni.showModal({
        title: '考试时间到',
        content: '考试时间已结束,系统将自动提交您的答案',
        showCancel: false,
        success: () => {
          this.submitExam();
        }
      });
    },

    submitExam() {
      this.stopTimer();
      this.examFinished = true;
      this.score = this.calculateScore();
      this.evaluation = this.getEvaluation(this.score, 100);
      this.$refs.popup.close();
      
      setTimeout(() => {
        if (this.$refs.resultPopup) {
          this.$refs.resultPopup.open();
        } else {
          uni.showToast({ title: '分数已计算完成', icon: 'none' });
          this.showResultDetail = true;
        }
      }, 100);
    },

    calculateScore() {
      let total = 0;
      this.questions.forEach(q => {
        total += this.getQuestionScore(q);
      });
      return total;
    },

    getEvaluation(score, total) {
      const percent = (score / total) * 100;
      if (percent < 60) return '评价: 继续努力!';
      if (percent < 90) return '评价: 还不错!';
      if (percent < 100) return '评价: 你真棒!';
      return '评价: 完美!全部答对!';
    },

    handleShowDetail() {
      this.showResultDetail = true;
      this.$refs.resultPopup.close();
    },

    exitExam() {
      if (this.$refs.resultPopup) {
        this.$refs.resultPopup.close();
      }
      uni.navigateBack();
    }
  }
}
</script>

<style>
/* 基础样式保持不变,强化选中状态样式 */
.exam-container {
  padding: 20rpx;
  display: flex;
  flex-direction: column;
  height: 100vh;
  background-color: #f5f5f5;
}

/* 修复:确保选中选项背景色明显生效 */
.option {
  padding: 25rpx;
  border-radius: 12rpx;
  background-color: #f5f5f5;
  transition: all 0.3s ease; /* 增加过渡效果,选中更流畅 */
  border: 2rpx solid transparent; /* 透明边框,选中时突出 */
}
/* 选中状态:蓝色背景+白色文字+边框强化 */
.option.selected {
  background-color: #2196F3 !important; /* !important确保优先级,避免被覆盖 */
  color: white !important;
  border-color: #1976D2;
  box-shadow: 0 4rpx 8rpx rgba(33, 150, 243, 0.2); /* 增加阴影,更醒目 */
}

/* 加载状态样式 */
.loading-options {
  font-size: 28rpx;
  color: #666;
  padding: 30rpx;
  text-align: center;
}

/* 答题卡样式 */
.sheet-item.unanswered {
  background-color: #e0e0e0;
  color: #666;
}
.sheet-item.answered {
  background-color: #2196F3;
  color: white;
}

/* 结果页样式 */
.detail-option.user-selected {
  background-color: #e3f2fd;
  border-left: 4rpx solid #2196F3;
}
.detail-option.correct-answer {
  background-color: #e8f5e9;
  border-left: 4rpx solid #4CAF50;
}
.detail-option.wrong-selected {
  background-color: #ffebee;
  border-left: 4rpx solid #F44336;
}

.your-answer-mark {
  color: #2196F3;
  font-size: 24rpx;
  margin-left: 10rpx;
  font-weight: bold;
}

.question-detail-item.unanswered {
  border-left-color: #9e9e9e;
  background-color: #f5f5f5;
}
.question-detail-item.unanswered .question-status {
  background-color: #e0e0e0;
  color: #666;
}

/* 其他原有样式保持不变 */
.exam-header {
  padding: 20rpx;
  background-color: #2196F3;
  color: white;
  border-radius: 10rpx;
  margin-bottom: 20rpx;
}

.exam-title {
  font-size: 36rpx;
  font-weight: bold;
  display: block;
  margin-bottom: 10rpx;
}

.exam-info {
  display: flex;
  justify-content: space-between;
  font-size: 28rpx;
}

.question-type {
  font-weight: bold;
  color: #FFEB3B;
}

.timer {
  color: #FFEB3B;
  font-weight: bold;
}

.timer.warning {
  color: #FF5252;
  animation: flash 1s infinite alternate;
}

@keyframes flash {
  from { opacity: 1; }
  to { opacity: 0.7; }
}

.question-container {
  flex: 1;
  margin-bottom: 20rpx;
  overflow-y: auto;
}

.question-box {
  background-color: #fff;
  border-radius: 15rpx;
  padding: 30rpx;
  box-shadow: 0 2rpx 10rpx rgba(0,0,0,0.1);
}

.q-title {
  font-size: 32rpx;
  line-height: 1.6;
  margin-bottom: 30rpx;
  color: #333;
  display: block;
}

.options-container {
  display: flex;
  flex-direction: column;
  gap: 20rpx;
}

.option.correct {
  background-color: #4CAF50;
  color: white;
}

.option.wrong {
  background-color: #F44336;
  color: white;
}

.exam-controls {
  display: flex;
  justify-content: space-between;
  margin-bottom: 20rpx;
}

.control-btn {
  flex: 1;
  margin: 0 10rpx;
  padding: 20rpx;
  font-size: 28rpx;
  border-radius: 8rpx;
  color: white;
  border: none;
}

.prev-btn {
  background-color: #607D8B;
}

.next-btn {
  background-color: #2196F3;
}

.submit-btn {
  background-color: #FF5722;
}

.answer-sheet {
  background-color: #fff;
  border-radius: 15rpx;
  padding: 20rpx;
  box-shadow: 0 2rpx 10rpx rgba(0,0,0,0.1);
  margin-bottom: 20rpx;
}

.sheet-title {
  font-size: 30rpx;
  font-weight: bold;
  margin-bottom: 15rpx;
  display: block;
}

.sheet-questions {
  display: flex;
  flex-wrap: wrap;
  gap: 15rpx;
  max-height: 200rpx;
  overflow-y: auto;
  padding-bottom: 10rpx;
}

.sheet-item {
  width: 80rpx;
  height: 80rpx;
  border-radius: 50%;
  display: flex;
  justify-content: center;
  align-items: center;
  font-size: 28rpx;
}

.sheet-item.current {
  border: 2rpx solid #FF5722;
  box-sizing: border-box;
}

.result-header {
  background-color: #2196F3;
  color: white;
  padding: 20rpx;
  border-radius: 10rpx;
  margin-bottom: 20rpx;
}

.result-title {
  font-size: 36rpx;
  font-weight: bold;
  display: block;
  margin-bottom: 10rpx;
}

.result-summary {
  display: flex;
  justify-content: space-between;
  font-size: 28rpx;
  margin-bottom: 15rpx;
}

.score {
  color: #FFEB3B;
  font-weight: bold;
}

.evaluation {
  font-style: italic;
}

.back-btn {
  background-color: #FF5722;
  color: white;
  border: none;
  padding: 15rpx 30rpx;
  border-radius: 8rpx;
  font-size: 28rpx;
}

.result-detail {
  flex: 1;
  overflow-y: auto;
}

.filter-controls {
  display: flex;
  justify-content: space-around;
  margin-bottom: 20rpx;
  flex-wrap: wrap;
  gap: 10rpx;
}

.filter-btn {
  background-color: #e0e0e0;
  border: none;
  padding: 15rpx 20rpx;
  border-radius: 8rpx;
  font-size: 26rpx;
}

.filter-btn.active {
  background-color: #2196F3;
  color: white;
}

.questions-detail {
  display: flex;
  flex-direction: column;
  gap: 20rpx;
}

.question-detail-item {
  background-color: #fff;
  border-radius: 15rpx;
  padding: 25rpx;
  box-shadow: 0 2rpx 10rpx rgba(0,0,0,0.1);
  border-left: 8rpx solid;
}

.question-detail-item.correct {
  border-left-color: #4CAF50;
}

.question-detail-item.wrong {
  border-left-color: #F44336;
}

.question-detail-item.partial {
  border-left-color: #FFC107;
}

.question-header {
  display: flex;
  justify-content: space-between;
  margin-bottom: 15rpx;
  font-size: 28rpx;
}

.question-num {
  font-weight: bold;
  color: #333;
}

.question-status {
  padding: 5rpx 15rpx;
  border-radius: 20rpx;
  font-size: 24rpx;
}

.question-detail-item.correct .question-status {
  background-color: #e8f5e9;
  color: #2e7d32;
}

.question-detail-item.wrong .question-status {
  background-color: #ffebee;
  color: #c62828;
}

.question-detail-item.partial .question-status {
  background-color: #fff8e1;
  color: #ff8f00;
}

.question-content {
  font-size: 28rpx;
  margin-bottom: 15rpx;
  line-height: 1.6;
}

.question-score {
  font-size: 24rpx;
  color: #666;
  margin-bottom: 15rpx;
  font-style: italic;
}

.options-detail {
  display: flex;
  flex-direction: column;
  gap: 15rpx;
}

.detail-option {
  padding: 15rpx;
  border-radius: 8rpx;
  font-size: 26rpx;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
}

.option-prefix {
  font-weight: bold;
  margin-right: 10rpx;
}

.option-content {
  flex: 1;
}

.correct-mark {
  color: #4CAF50;
  font-size: 24rpx;
  margin-left: 10rpx;
  font-weight: bold;
}

.wrong-mark {
  color: #F44336;
  font-size: 24rpx;
  margin-left: 10rpx;
  font-weight: bold;
}
</style>
单选题JSON数据库
题库内容涉及敏感词无法放置格式如下
[
  {
    "id": 1,
    "type": "single",
    "question": "",
    "options": [
      "规章制度",
      "自治条例",
      "国家法规政策",
      "地方性法规"
    ],
    "answer": [
      2
    ]
  },
  {
    "id": 2,
    "type": "single",
    "question": "",
    "options": [
      "规范",
      "平稳",
      "有序",
      "安全"
    ],
    "answer": [
      3
    ]
  }
]  
多选题JSON数据库
题库内容涉及敏感词无法放置格式如下
[
    {
        "id": 401,
        "type": "multi",
        "question": "《安全生产管理知识》指导用书把施工过程中的安全生产管理知识分别设置为( )。",
        "options": [
            "管理",
            "实操",
            "法规",
            "法律",
            "政策"
        ],
        "answer": [0,2,4]
    },
    {
        "id": 402,
        "type": "multi",
        "question": "安全生产管理一般包括( )。",
        "options": [
            "",
            "安全生产管理的理论学说",
            "安全生产管理的经济学说",
            "安全生产管理的实践和经验",
            ""
        ],
        "answer": [0,1,3,4]
    }
]
判断题JSON数据库
题库内容涉及敏感词无法放置格式如下
[
  {
      "id": 601,
      "type": "judge",
      "question": "安全生产管理一般包括四个部分,再往外延伸还会涉及安全生产管理的甲方服务等。",
      "options": ["正确", "错误"],
      "answer": [1]
  },
  {
      "id": 602,
      "type": "judge",
      "question": "",
      "options": ["正确", "错误"],
      "answer": [1]
  }
]
posted @ 2025-10-15 13:29  小神龙_007  阅读(14)  评论(0)    收藏  举报