前言:
前端实现试卷作答,功能无非就是用户点击选项之后,判断是否作答正确;回显所选答案,但也有几个常见的问题:
- 后端返回的数据结构如何设计?
- 像单选题/判断题,当用户点击时可以直接记录用户所选的答案,但是多选题怎么记录?
- 对于已作答试题,如何回显?
- 用户点击的选项、正确的选项、错误的选项怎么用不同的样式显示?
实现效果:
- 对于单选题/判断题而言,只要用户点击其中一个选项,就会立马做出判断
- 对于多选题而言,用户点击所要的选项之后,还要点击"确认提交"按钮才会做出判断
- 所有的单选/判断/多选都作答完毕之后,才会取消“提交试卷”按钮的禁用状态
先上效果图:
后端返回数据设计:
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: '这是判断题',
}
]
解释一下一些字段的作用:
isShow
:每道题都有一个isShow
,用来判断用户是否答过这道题了;如果这道题用户答过了,就会判断用户所作答的答案是否正确;而且后续是否能提交卷子也要根据每道题的这个字段做判断selectList[]
:每道题都有一个selectList[]
,用来判断用户对这道题的作答情况,也就是选了哪一选项就把选项放进去;对于单选题,如果selectList[]
长度 !== 0,说明这道题已经选择过某个选项了,直接return;对于多选题,如果selectList[]
还没有存储当前选中
的这个选项,则把这个选项放进去,如果selectList[]
有存储当前选中的这个选项,此时就意味着用户是要移除这个选项
想象一下,多选题的同一个选项,第一次按是选中,再按一次就是取消选中;另外,再次说明单选题/判断题和多选题判断是否作答正确的时机: 对于单选题/判断题而言,只要用户点击其中一个选项,就会立马做出判断;对于多选题而言,用户点击所要的选项之后,还要点击"确认提交"按钮才会做出判断
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;
}
缓存答案与回显已作答试题:
当用户未“提交试卷”就选择退出的话,可以缓存用户已作答部分
思路:
- 本地缓存:方便,但不优雅(这里采用第一种方法,因为时间不够...所以只能暴力本地存储)
- 交给后端:
用户在作答过程中,会改变
questions[]
每一项的isShow
、selectList[]
等,所以可以直接交给后端已改变之后
的数据,下一次用户再次进入答题时,拿到的就会是覆盖之后
的数据(也就是有部分答题记录的数据)
当然,前提是在调用接口获取试题的时候后端有根据用户id等唯一信息过一遍筛,不然肯定会造成数据污染。很好理解,同一套卷子,甲做了两题,然后退出了,试题此时就覆盖了;但对于乙来说,他可能还没做过题,如果不过一遍筛,乙拿到的也会是做了两道题的数据
体验优化:
- 用户没做完题目要退出时,可以来个
Modal
模态框,询问用户是否确认要退出 - 用户确实做一半就退出,下一次再进入时,询问用户是否要回显作答记录。如果要,再显示已作答试题