最近公司谈了个项目,需要做一个课程系统,学生在每个课时的最后需要完成课后习题,目前做了个初版的简易答题表单,后续根据项目进展会持续优化。
需求分析
- 首先,题目类型有三种,分别是:单选、多选、判断。
- 单选和多选会有多个选项,判断题一般是 2 个选项。
- 单选和判断只有1个答案,多选题会有 1 个或多个答案。
- 每道题目都分为,题号、标题、选项、答案、解析,这几个模块。三种题型大同小异。 下图是大概的组件构成。
组件需要支持控制,学生提交习题后,表单不可编辑。
根据数据结构设计组件
数据结构如下(后台提供的数据结构是带表名的,我这边就直接用了,没有做过滤)
enum QuestionType {
Single = '1',
Multiple = '2',
Judge = '3',
}
interface Question {
psId: number //题目 id
rowNum: number // 题号
psDescription: string // 题目标题
psType: QuestionType // 题目类型
psAnswer: string // 正确答案
psAnswerDescription: string // 答案解析
sswAnswer: string // 学生答案
sswFlag: '0' | '1' // 回答错误/正确
choices: Choice[] // 选项
}
interface Choice {
pscId: number // 选项 id
psId: number // 题目 id
pscChoice: string // 选项编号 A/B/C/D ...
pscChoiceDescription: string // 选项内容
}
interface Response {
singleChoiceList: Question[] // 单选
multipleChoiceList: Question[] // 多选
judgeChoiceList: Question[] // 判断
}
-
这里需要注意的是,后台设计的数据结构中,
正确答案 psAnswer和学生答案 sswAnswer都是string类型,多选题有多个答案需要用逗号分割,比如 'A,B,C' -
根据需求和数据结构,我决定设计三个组件,
QuestionWrapper、RenderQuestion、RenderChoice
代码
QuestionWrapper.tsx
QuestionWrapper只是一个壳,用来包裹所有的子组件,并为子组件提供 disabled 状态
export const QuestionWrapper = defineComponent({
props: {
disabled: {
type: Boolean,
default: false,
},
},
setup(props, { slots }) {
// 暴露 disabled 状态给子组件
provide('question-disabled', props.disabled)
return () => {
return (
<div class="flex flex-col">
{slots.default?.()}
</div>
)
}
},
})
RenderQuestion.tsx
RenderQuestion 渲染题目、正确答案、解析, 嵌套RenderChoice组件,渲染选项。
import { RenderChoice } from './RenderChoice'
import type { Question } from '@/api/section'
export const RenderQuestion = defineComponent({
props: {
question: {
type: Object as PropType<Question>,
required: true,
},
},
setup(props) {
return () => {
const { question } = props
if (!question.choices || !question.choices.length) {
return null
}
return (
<>
<div class="flex flex-col">
{/* 渲染问题的行号和描述 */}
<p class="px-16 py-8 text-lv1">
{ question.rowNum }
.
{' '}
{question.psDescription}
</p>
{/* 渲染问题的选项 */}
<RenderChoice
choices={question.choices}
type={question.psType}
v-model:answer={question.sswAnswer}
/>
{
// 只有错误的题目才显示正确答案和解析
question.sswFlag === '0' && (
<div class="px-16 py-8 text-13 text-lv1">
<div class={question.sswFlag === '0' && 'text-rose'}>
正确答案:
{ question.psAnswer }
</div>
<p class="text-12 leading-relaxed">
解析:
{
question.psAnswerDescription
? question.psAnswerDescription
: '略'
}
</p>
</div>
)
}
</div>
</>
)
}
},
})
RenderChoice.tsx
项目配置了 vant 组件的自动导入,但是在 tsx 文件中不生效,所以所有组件都手动导入了一遍。知道怎么配置的朋友可以评论区留言,感恩。
import { Cell as VanCell, CellGroup as VanCellGroup, Checkbox as VanCheckBox, CheckboxGroup as VanCheckboxGroup, Radio as VanRadio, RadioGroup as VanRadioGroup } from 'vant'
import 'vant/lib/cell/index.css'
import 'vant/lib/cell-group/index.css'
import 'vant/lib/radio/index.css'
import 'vant/lib/radio-group/index.css'
import 'vant/lib/checkbox/index.css'
import 'vant/lib/checkbox-group/index.css'
import { type Choice, QuestionType } from '@/api/section'
export const RenderChoice = defineComponent({
props: {
choices: {
type: Array as PropType<Array<Choice>>,
required: true,
},
answer: {
type: String,
},
type: {
type: String as PropType<QuestionType>,
required: true,
},
},
components: {
VanCell,
VanCellGroup,
VanRadio,
VanRadioGroup,
VanCheckBox,
VanCheckboxGroup,
},
emits: ['update:answer'],
setup(props, { emit }) {
// 注入父组件的 disabled 状态
const parentDisabled: boolean = inject('question-disabled')
// 定义复选框选中的值
const checkBoxChecked = ref(props.answer?.split(',') || [])
const checkboxRefs = ref([])
// 切换复选框的选中状态
const toggleCheckBox = (index: number) => {
checkboxRefs.value[index].toggle()
}
// 处理多选答案的变化
const onMultipleAnswerChange = (index: number) => {
if (parentDisabled)
return
toggleCheckBox(index)
emit('update:answer', checkBoxChecked.value.join())
}
return () => {
const { choices, answer, type } = props
const radioChecked = ref(answer)
const onSingleAnswerChange = (val: string) => {
if (parentDisabled)
return
emit('update:answer', val)
}
return (
<>
<div class="flex flex-col">
{
// 渲染单选或判断题
(type === QuestionType.Single || type === QuestionType.Judge) && (
<VanRadioGroup v-model={radioChecked.value} disabled={parentDisabled}>
<VanCellGroup inset>
<>
{
// 遍历 choices 渲染单选项
choices.map(choice => (
<VanCell
clickable={!parentDisabled}
onClick={() => onSingleAnswerChange(choice.pscChoice)}
>
{{
'title': () => (
<>
<p>
<>
{type !== QuestionType.Judge && (
<>
{choice.pscChoice}
.
{' '}
</>
)}
</>
{ choice.pscChoiceDescription }
</p>
</>
),
'right-icon': () => (
<VanRadio
name={choice.pscChoice}
/>
),
}}
</VanCell>
))
}
</>
</VanCellGroup>
</VanRadioGroup>
)
}
{
// 渲染多选题
type === QuestionType.Multiple && (
<VanCheckboxGroup
v-model={checkBoxChecked.value}
shape="square"
disabled={parentDisabled}
>
<VanCellGroup inset>
{
// 遍历 choices 渲染多选项
choices.map((choice, index) => (
<VanCell
clickable={!parentDisabled}
onClick={() => onMultipleAnswerChange(index)}
>
{{
'title': () => (
<>
<p>
{choice.pscChoice}
.
{' '}
{ choice.pscChoiceDescription }
</p>
</>
),
'right-icon': () => (
<VanCheckBox
ref={el => checkboxRefs.value[index] = el}
name={choice.pscChoice}
onClick={(e: Event) => {
e.stopPropagation()
onMultipleAnswerChange(index)
}}
/>
),
}}
</VanCell>
))
}
</VanCellGroup>
</VanCheckboxGroup>
)
}
</div>
</>
)
}
},
})
父页面
<QuestionWrapper :disabled="isSubmitted">
<div
class="flex items-center gap-2 px-16 py-8 font-semibold text-lv1"
>
<div class="i-carbon-radio-button-checked text-brand-primary" />
单选题
</div>
<RenderQuestion
v-for="sq in section.singleChoiceList"
:key="sq.psId"
:question="sq"
/>
<div
class="flex items-center gap-2 px-16 py-8 font-semibold text-lv1"
>
<div class="i-carbon-checkbox-checked text-brand-primary" />
多选题
</div>
<RenderQuestion
v-for="mq in section.multipleChoiceList"
:key="mq.psId"
:question="mq"
/>
<div
class="flex items-center gap-2 px-16 py-8 font-semibold text-lv1"
>
<div class="i-carbon-spell-check text-brand-primary" />
判断题
</div>
<RenderQuestion
v-for="jq in section.judgeChoiceList"
:key="jq.psId"
:question="jq"
/>
</QuestionWrapper>
最终效果
答题
解析
End
项目第一版目前已经交付了,后续就跟着需求,走一步看一步吧。希望我的文章能帮到一些人,为应对类似的需求提供一些思路。