前端实现试卷作答之单选、判断、多选

754 阅读7分钟

前言:

前端实现试卷作答,功能无非就是用户点击选项之后,判断是否作答正确;回显所选答案,但也有几个常见的问题:

  1. 后端返回的数据结构如何设计?
  2. 像单选题/判断题,当用户点击时可以直接记录用户所选的答案,但是多选题怎么记录?
  3. 对于已作答试题,如何回显?
  4. 用户点击的选项、正确的选项、错误的选项怎么用不同的样式显示?

实现效果:

  1. 对于单选题/判断题而言,只要用户点击其中一个选项,就会立马做出判断
  2. 对于多选题而言,用户点击所要的选项之后,还要点击"确认提交"按钮才会做出判断
  3. 所有的单选/判断/多选都作答完毕之后,才会取消“提交试卷”按钮的禁用状态

先上效果图:

image.png

image.png

image.png

image.png

后端返回数据设计:

questions: [
  {
    question_id: 5, // 问题id
    type: '多选题', // 题目类型
    isShow: false, // 用来判断是否进行答案的判断(只有点击提交该题才改变状态)
    answerList: [  // 选项列表(包含问题描述,以及该选项是否正确)
      { name: '选项一', isAnswer: true },
      { name: '选项二', isAnswer: false },
      { name: '选项三', isAnswer: false },
      { name: '选项四', isAnswer: true }
    ],
    selectList: [], // 用户选中了哪个/哪些选项
    description: '这是多选题', // 题目
  },
  {
    question_id: 312,
    type: '单选题',
    isShow: false, 
    answerList: [
      { name: '单选一', isAnswer: true },
      { name: '单选一', isAnswer: false }
    ],
    selectList: [],
    description: '这是单选题',
  },
   {
    question_id: 312,
    type: '判断题',
    isShow: false, 
    answerList: [
      { name: '判断一', isAnswer: true },
      { name: '判断二', isAnswer: false }
    ],
    selectList: [],
    description: '这是判断题',
  }
]

解释一下一些字段的作用:

  1. isShow:每道题都有一个isShow,用来判断用户是否答过这道题了;如果这道题用户答过了,就会判断用户所作答的答案是否正确;而且后续是否能提交卷子也要根据每道题的这个字段做判断
  2. selectList[]:每道题都有一个selectList[],用来判断用户对这道题的作答情况,也就是选了哪一选项就把选项放进去;对于单选题,如果selectList[]长度 !== 0,说明这道题已经选择过某个选项了,直接return;对于多选题,如果selectList[]还没有存储当前选中的这个选项,则把这个选项放进去,如果selectList[]有存储当前选中的这个选项,此时就意味着用户是要移除这个选项

想象一下,多选题的同一个选项,第一次按是选中,再按一次就是取消选中;另外,再次说明单选题/判断题和多选题判断是否作答正确的时机: 对于单选题/判断题而言,只要用户点击其中一个选项,就会立马做出判断;对于多选题而言,用户点击所要的选项之后,还要点击"确认提交"按钮才会做出判断

  1. answerList:表示每道题的选项列表,通过循环渲染出来,每个字段除了选项内容,还有一个isAnswer字段,表示该选项是不是正确选项

正确/错误答案、用户所选答案在页面中怎么区分?

答案是绑定动态样式,依据样式之间的相互覆盖实现不同场景下的选项的不同显示效果

样式区分此处笔者的是:用户选中的选项就用蓝色背景+蓝色字;一道题判断正确与否之后,正确选项用绿色背景+绿色字,错误答案用红色背景+红色字

代码实现:

场景是uniapp微信小程序,所以用了一个swiper组件,每个swiper-item存放一道题

<!-- 布局代码 -->
<!-- 试题swiper区域(如果有试题的话) -->
<template v-if="questionList.length > 0">
  <swiper
    class="swiper"
    :style="{ height: screenHeight - 44 + 'px' }"
    :current="current"
    @change="onSwiperChange"
    >
    <swiper-item v-for="item in questionList" :key="item.question_id">
      <view class="item" :style="{ height: screenHeight - 44 + 'px' }">
        <!-- 题目描述 -->
        <view class="detail">
          <span>({{ item.type }})</span>
          <span>{{ item.description }}</span>
        </view>

        <view>
          <!-- 多选题的作答区域 -->
          <view class="answer" v-if="item.type === '多选题'">
            <view
              class="answer-item"
              :class="{
              'ac-select': item.selectList.indexOf(answer) !== -1,
              'ac-right': answer.isAnswer && item.isShow,
              'ac-wrong':
              item.selectList.indexOf(answer) !== -1 && !answer.isAnswer && item.isShow
              }"
              v-for="(answer, index) in item.answerList"
              :key="answer.name"
              @click="selectMultiple(item, answer, index)"
              >
              <view class="turn">
                {{ String.fromCharCode(64 + parseInt(index + 1)) }}
              </view>
              <view class="option">
                {{ answer.name }}
              </view>
            </view>
          </view>

          <!-- 单选题的作答区域 -->
          <view class="answer" v-else-if="item.type === '单选题'">
            <view
              class="answer-item"
              :class="{
              'ac-select': item.selectList.indexOf(answer) !== -1,
              'ac-right': answer.isAnswer && item.isShow,
              'ac-wrong':
              item.selectList.indexOf(answer) !== -1 && !answer.isAnswer && item.isShow
              }"
              v-for="(answer, index) in item.answerList"
              :key="answer.name"
              @click="selectRadio(item, answer)"
              >
              <view class="turn">
                {{ String.fromCharCode(64 + parseInt(index + 1)) }}
              </view>
              <view class="option">
                {{ answer.name }}
              </view>
            </view>
          </view>

          <!-- 判断题的作答区域 -->
          <view class="answer" v-else-if="item.type === '判断题'">
            <view
              class="answer-item"
              :class="{
              'ac-select': item.selectList.indexOf(answer) !== -1,
              'ac-right': answer.isAnswer && item.isShow,
              'ac-wrong':
              item.selectList.indexOf(answer) !== -1 && !answer.isAnswer && item.isShow
              }"
              v-for="(answer, index) in item.answerList"
              :key="answer.name"
              @click="selectRadio(item, answer)"
              >
              <view class="turn">
                {{ String.fromCharCode(64 + parseInt(index + 1)) }}
              </view>
              <view class="option">
                {{ answer.name }}
              </view>
            </view>
          </view>
        </view>

        <!-- 多选题提交按钮(只有多选题才有) -->
        <view class="btn" v-if="item.type === '多选题'">
          <u-button
            type="primary"
            class="submit"
            @click="submit(item)"
            :disabled="item.isShow"
            >
            确认提交
          </u-button>
        </view>

        <!-- 上一题/下一题切换 -->
        <view class="meau">
          <u-button
            type="primary"
            class="prev"
            @click="prev"
            v-if="current !== 0"
            >
            上一题
          </u-button>
          <u-button
            type="primary"
            class="next"
            @click="next"
            v-if="current !== total - 1"
            >
            下一题
          </u-button>
          <u-button
            type="primary"
            class="next"
            @click="finish"
            v-if="current === total - 1"
            >
            提交问卷
          </u-button>
        </view>
      </view>
    </swiper-item>
  </swiper>
</template>

  <!-- 如果没试题,消息提示 -->
  <template v-else>
    <notList></notList>
  </template>
// 逻辑代码
<script>
export default {
  data() {
    return {
      total: 0, // 试题的总数
      screenHeight: 0,
      current: 0, // 当前播放第几题
      questionList: [], // 试题列表
    }
  },
  onLoad(option) {
    // 获取屏幕高度
    const { windowHeight } = uni.getSystemInfoSync()
    this.screenHeight = windowHeight
    // 调用接口获取试题(此处前端模拟实现)
    // this.fetchData()
  },
  computed: {
    // 判断是否可以提交整套卷子
    isTabled() {
      const res = this.questionList.findIndex((item) => item.isShow === false)
      return res === -1 ? true : false
    }
  },
  methods: {
    //  获取问题总数
    async fetchData() {
      const questionList = await xxxAPI()
      // 试题总数
      this.total = questionList.length
      this.questionList = questionList
    },
    onSwiperChange({ detail: { current } }) {
      this.current = current
    },
    // 多选题选中
    selectMultiple(item, answer, index) {
      // 说明已经点过提交按钮了
      if (item.isShow) return
      // 如果已经选中该选项,此时再点击就是移除该选项
      const position = item.selectList.indexOf(answer)
      if (position !== -1) {
        item.selectList.splice(position, 1)
      } else {
        item.selectList.push(answer)
      }
    },
    // 多选题提交
    submit(item) {
      if (item.selectList.length === 0)
        return uni.showToast({
          icon: 'none',
          title: '请选中选项'
        })
      // 改变这一题的显示状态(修改为true,表示已经点击过提交了)
      item.isShow = true
    },
    // 单选题选中
    selectRadio(item, answer) {
      // 单选题是一选中就显示结果,如果 selectList.length !== 0,说明已经选择选项了
      if (item.selectList.length !== 0) return
      item.isShow = true
      item.selectList.push(answer)
    },
    // 下一题
    next() {
      this.current++
    },
    // 上一题
    prev() {
      this.current--
    },
    // 提交试卷
    async finish() {
      // 试题还没做完,不能提交
      if (!this.isTabled) {
        return uni.showToast({
          icon: 'none',
          title: '试卷未作答完毕,无法提交'
        })
      }
      // 调用提交试卷API
      await postPaperAPI(xxx)

      // 页面回退
      uni.navigateBack()
      
      // 消息提示
    },
    // 取消退出作答
    onCancel() {
      this.modalShow = false
    }
  }
}
</script>

JS的indexOf()方法会返回元素第一次出现的位置

/* 样式代码 */
.swiper {
  .item {
    padding: 20rpx;

    .detail {
      margin-top: 20rpx;
      line-height: 1.5;
      color: #010101;
      font-size: 16px;
    }

    .answer {
      margin-top: 20rpx;

      .answer-item {
        height: auto;
        padding: 15rpx;
        font-size: 16px;
        color: #55545c;
        line-height: 1.5;
        border-radius: 10rpx;
        display: flex;
        align-items: center;
        margin: 10rpx 0;

        .turn {
          width: 50rpx;
          height: 50rpx;
          background-color: #d0d1d0;
          line-height: 50rpx;
          text-align: center;
          border-radius: 50%;
          margin-right: 15rpx;
        }
        .option {
          flex: 1;
        }
      }
    }

    .btn {
      margin-top: 20rpx;
    }

    .meau {
      display: flex;
      justify-content: center;
      margin-top: 20rpx;

      .prev {
        margin-right: 20rpx;
      }
    }
  }
}

.ac-select {
  background-color: #ecf5ff !important;
  color: #409eff !important;
}

.ac-right {
  background-color: #f0f9eb !important;
  color: #67c23a !important;
}

.ac-wrong {
  background-color: #fef0f0 !important;
  color: #f56c6c !important;
}

.turn-select {
  background-color: #409eff !important;
  color: #fff;
}

.turn-right {
  background-color: #67c23a !important;
  color: #fff;
}

.turn-wrong {
  background-color: #f56c6c !important;
  color: #fff;
}

缓存答案与回显已作答试题:

当用户未“提交试卷”就选择退出的话,可以缓存用户已作答部分

思路:

  1. 本地缓存:方便,但不优雅(这里采用第一种方法,因为时间不够...所以只能暴力本地存储)
  2. 交给后端: 用户在作答过程中,会改变questions[]每一项的isShowselectList[]等,所以可以直接交给后端已改变之后的数据,下一次用户再次进入答题时,拿到的就会是覆盖之后的数据(也就是有部分答题记录的数据)

当然,前提是在调用接口获取试题的时候后端有根据用户id等唯一信息过一遍筛,不然肯定会造成数据污染。很好理解,同一套卷子,甲做了两题,然后退出了,试题此时就覆盖了;但对于乙来说,他可能还没做过题,如果不过一遍筛,乙拿到的也会是做了两道题的数据

体验优化:

  1. 用户没做完题目要退出时,可以来个Modal模态框,询问用户是否确认要退出
  2. 用户确实做一半就退出,下一次再进入时,询问用户是否要回显作答记录。如果要,再显示已作答试题