做一个简易的答题表单Vant4+TSX

563 阅读3分钟

最近公司谈了个项目,需要做一个课程系统,学生在每个课时的最后需要完成课后习题,目前做了个初版的简易答题表单,后续根据项目进展会持续优化。

需求分析

  • 首先,题目类型有三种,分别是:单选、多选、判断。
  • 单选和多选会有多个选项,判断题一般是 2 个选项。
  • 单选和判断只有1个答案,多选题会有 1 个或多个答案。

image.png

  • 每道题目都分为,题号、标题、选项、答案、解析,这几个模块。三种题型大同小异。 下图是大概的组件构成。

image.png

组件需要支持控制,学生提交习题后,表单不可编辑。

根据数据结构设计组件

数据结构如下(后台提供的数据结构是带表名的,我这边就直接用了,没有做过滤)


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'

  • 根据需求和数据结构,我决定设计三个组件,QuestionWrapperRenderQuestionRenderChoice

代码

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>

最终效果

答题

20240906_143908.gif

解析

20240906_144510.gif

End

项目第一版目前已经交付了,后续就跟着需求,走一步看一步吧。希望我的文章能帮到一些人,为应对类似的需求提供一些思路。