Files
super_video/src/components/QuestionQuiz/QuestionQuiz.vue
T
2026-09-21 10:30:13 +08:00

417 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { getStaticImage, getVideoPoster } from '@/utils'
import { Video } from '@/components'
export interface QuestionOption {
id: string
label: string
}
export interface Question {
id: number
title: string
description: string
options: QuestionOption[]
correctOptionId: string // 单选题的正确答案ID,如果为空字符串,表示引导题,任意选择都可进入下一题
correctOptionIds?: string[] // 多选题的正确答案ID数组
isMultiple?: boolean // 是否为多选题,默认为 false
mediaSrc?: string // 图片路径或视频 URL,通过文件后缀判断类型
explanation?: string // 错误答案解析(可选)
}
interface Props {
questions: Question[]
showQuestionNumber?: boolean // 是否显示题号,默认为 true
}
const props = withDefaults(defineProps<Props>(), {
showQuestionNumber: true,
})
const emit = defineEmits<{
complete: []
}>()
const currentIndex = ref(0)
const selectedOptionId = ref<string | null>(null) // 单选题选中的选项
const selectedOptionIds = ref<string[]>([]) // 多选题选中的选项数组
const wrongAnswerCount = ref(0) // 当前题答错次数
const showWrongFeedback = ref(false) // 是否显示答错提示
const showErrorExplanation = ref(false) // 是否显示错误解析
const showCorrectFeedback = ref(false)
const VIDEO_EXTENSIONS = [
'.mp4',
'.mov',
'.avi',
'.webm',
'.mkv',
'.flv',
'.wmv',
]
const IMAGE_EXTENSIONS = [
'.png',
'.jpg',
'.jpeg',
'.gif',
'.webp',
'.svg',
'.bmp',
]
const total = computed(() => props.questions.length)
const currentQuestion = computed(() => props.questions[currentIndex.value])
const hasMedia = computed(() => !!currentQuestion.value?.mediaSrc)
const isMultiple = computed(() => currentQuestion.value?.isMultiple ?? false)
const correctOption = computed(() => {
if (!currentQuestion.value) return null
return currentQuestion.value.options.find(
(opt) => opt.id === currentQuestion.value.correctOptionId,
)
})
const correctOptions = computed(() => {
if (!currentQuestion.value || !currentQuestion.value.correctOptionIds)
return []
return currentQuestion.value.options.filter((opt) =>
currentQuestion.value.correctOptionIds?.includes(opt.id),
)
})
// 判断选项数量是否超过4个
const hasMoreThanFourOptions = computed(() => {
return currentQuestion.value?.options.length > 4
})
// 切换题目时重置选择状态
watch(currentIndex, () => {
selectedOptionId.value = null
selectedOptionIds.value = []
wrongAnswerCount.value = 0
showWrongFeedback.value = false
showErrorExplanation.value = false
showCorrectFeedback.value = false
})
// 通过文件后缀判断媒体类型
const getFileExtension = (src: string): string => {
const cleanSrc = src.split('?')[0].split('#')[0].toLowerCase()
const dotIndex = cleanSrc.lastIndexOf('.')
return dotIndex > -1 ? cleanSrc.slice(dotIndex) : ''
}
const isVideo = (src: string): boolean => {
return VIDEO_EXTENSIONS.includes(getFileExtension(src))
}
const isImage = (src: string): boolean => {
return IMAGE_EXTENSIONS.includes(getFileExtension(src))
}
const isOptionSelected = (optionId: string) => {
return isMultiple.value
? selectedOptionIds.value.includes(optionId)
: selectedOptionId.value === optionId
}
const isCorrectOption = (optionId: string) => {
const q = currentQuestion.value
if (!q) return false
return isMultiple.value
? (q.correctOptionIds?.includes(optionId) ?? false)
: optionId === q.correctOptionId
}
const getOptionClass = (optionId: string) => {
const selected = isOptionSelected(optionId)
const correct = isCorrectOption(optionId)
if (showErrorExplanation.value) {
if (selected && correct) {
return 'border-emerald-400 bg-emerald-500/30 shadow-lg shadow-emerald-500/30'
}
if (correct) {
return 'border-emerald-400 bg-emerald-500/20 shadow-lg shadow-emerald-500/20'
}
if (selected && !correct) {
return 'border-red-400 bg-red-500/20 shadow-lg shadow-red-500/20'
}
return 'border-white/15 bg-white/5 hover:bg-white/10 hover:border-white/25'
}
if (showWrongFeedback.value && selected) {
return 'border-red-400 bg-red-500/20 shadow-lg shadow-red-500/20'
}
if (selected) {
return 'border-emerald-400 bg-emerald-500/20 shadow-lg shadow-emerald-500/20'
}
return 'border-white/15 bg-white/5 hover:bg-white/10 hover:border-white/25'
}
const hasSelectedAnswer = () => {
return isMultiple.value
? selectedOptionIds.value.length > 0
: !!selectedOptionId.value
}
const isCurrentAnswerCorrect = () => {
const q = currentQuestion.value
if (!q) return false
if (isMultiple.value) {
const correctIds = q.correctOptionIds || []
const selectedIds = selectedOptionIds.value
if (correctIds.length === 0) return false
if (selectedIds.length !== correctIds.length) return false
const correctSet = new Set(correctIds)
return selectedIds.every((id) => correctSet.has(id))
}
const isGuideQuestion = q.correctOptionId === '' // 引导题判断
return isGuideQuestion || selectedOptionId.value === q.correctOptionId
}
const handleOptionClick = (optionId: string) => {
if (showCorrectFeedback.value) return
if (isMultiple.value) {
// 多选题:切换选中状态
const index = selectedOptionIds.value.indexOf(optionId)
if (index > -1) {
selectedOptionIds.value.splice(index, 1)
} else {
selectedOptionIds.value.push(optionId)
}
} else {
// 单选题:直接选中
selectedOptionId.value = optionId
}
// 选择新选项时隐藏错误提示和解析
showWrongFeedback.value = false
showErrorExplanation.value = false
showCorrectFeedback.value = false
}
const goNextQuestion = async () => {
showWrongFeedback.value = false
showErrorExplanation.value = false
showCorrectFeedback.value = true
await new Promise((resolve) => setTimeout(resolve, 800))
if (currentIndex.value < total.value - 1) {
currentIndex.value += 1
} else {
showCorrectFeedback.value = false
emit('complete')
}
}
// 交互规则:选对进入下一步,选错显示解析;最后一题选对触发 complete 事件
// 如果 correctOptionId 为空字符串,表示引导题,任意选择都可进入下一题
const handleSubmit = async () => {
if (showCorrectFeedback.value) return
if (!hasSelectedAnswer()) return
if (!isCurrentAnswerCorrect()) {
wrongAnswerCount.value += 1
showWrongFeedback.value = true
showErrorExplanation.value = wrongAnswerCount.value >= 3
return
}
await goNextQuestion()
}
</script>
<template>
<div
class="absolute inset-0 flex items-center justify-center px-20 pointer-events-auto z-30"
@touchstart.stop
@click.stop
>
<div
class="bg-black/80 backdrop-blur-lg rounded-3xl border border-white/20 p-16 space-y-10 text-white pointer-events-auto"
>
<!-- 顶部:题号 -->
<div
v-if="showQuestionNumber"
class="flex items-center justify-between mb-2"
>
<div class="text-3xl font-semibold">
第
<span class="text-4xl font-extrabold mx-1 text-emerald-300">
{{ currentQuestion.id }}
</span>
页
<span class="text-2xl opacity-70 ml-2">/ 共 {{ total }} 页</span>
</div>
</div>
<!-- 中部:左右布局 -->
<div
:class="[
hasMedia
? 'grid grid-cols-[1.2fr,1.3fr] gap-8 items-stretch'
: 'flex justify-center',
]"
>
<!-- 左侧:题干 + 选项 -->
<div :class="['flex flex-col gap-10', !hasMedia && 'max-w-2xl w-full']">
<div class="space-y-8">
<h2 class="text-3xl font-bold leading-relaxed">
{{ currentQuestion.title }}
</h2>
<p class="text-2xl leading-7 opacity-90">
{{ currentQuestion.description }}
</p>
</div>
<div
:class="
hasMoreThanFourOptions ? 'grid grid-cols-2 gap-6' : 'space-y-10'
"
>
<button
v-for="opt in currentQuestion.options"
:key="opt.id"
type="button"
class="w-full text-left px-5 py-4 rounded-xl border transition-all duration-200 text-2xl leading-6"
:class="getOptionClass(opt.id)"
@click="handleOptionClick(opt.id)"
>
<span class="font-semibold mr-2">
{{ opt.id.toUpperCase() }}.
</span>
<span>{{ opt.label }}</span>
</button>
</div>
<!-- 正确反馈提示 -->
<Transition name="fade">
<div
v-if="showCorrectFeedback"
class="mt-4 p-6 rounded-xl bg-emerald-500/20 border border-emerald-400/30 text-2xl font-semibold text-emerald-100"
>
回答正确
</div>
</Transition>
<!-- 错误选择提示 -->
<Transition name="fade">
<div
v-if="showWrongFeedback && !showErrorExplanation"
class="mt-4 p-6 rounded-xl bg-red-500/20 border border-red-400/30 text-2xl font-semibold text-red-100"
>
回答错误,请再试一次
</div>
</Transition>
<!-- 错误解析提示 -->
<Transition name="fade">
<div
v-if="showErrorExplanation && currentQuestion.explanation"
class="mt-4 p-6 rounded-xl bg-amber-500/20 border border-amber-400/30 space-y-3"
>
<div class="flex items-start gap-3">
<span class="text-2xl">💡</span>
<div class="flex-1 space-y-2">
<div class="text-xl font-semibold text-amber-300">
参考答案
</div>
<div
v-if="isMultiple && correctOptions.length > 0"
class="text-lg text-amber-100 leading-relaxed"
>
{{
correctOptions
.map((opt) => `${opt.id.toUpperCase()}. ${opt.label}`)
.join('、')
}}
</div>
<div
v-else-if="correctOption"
class="text-lg text-amber-100 leading-relaxed"
>
{{ correctOption.id.toUpperCase() }}.
{{ correctOption.label }}
</div>
<div class="text-xl font-semibold text-amber-300 mt-4">
解析
</div>
<div class="text-lg text-amber-100 leading-relaxed">
{{ currentQuestion.explanation }}
</div>
</div>
</div>
</div>
</Transition>
<div :class="!hasMedia && 'flex justify-center'">
<button
type="button"
:disabled="
showCorrectFeedback ||
(isMultiple
? selectedOptionIds.length === 0
: !selectedOptionId)
"
class="inline-flex items-center justify-center px-10 py-3 rounded-full bg-emerald-500 hover:bg-emerald-400 active:bg-emerald-600 text-white text-2xl font-bold shadow-lg shadow-emerald-500/40 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-emerald-500 hover:shadow-xl hover:shadow-emerald-500/50 hover:scale-105 active:scale-100"
@click="handleSubmit"
>
确认选择
</button>
</div>
</div>
<!-- 右侧:参考图片 / 视频 -->
<div
class="flex flex-col gap-3"
v-if="currentQuestion.mediaSrc"
:key="currentQuestion.id"
>
<!-- <div class="text-2xl font-semibold text-white/80 px-1">题目参考画面</div> -->
<div
class="relative rounded-3xl overflow-hidden border border-white/20 bg-black/50 min-h-[500px] shadow-lg"
>
<template v-if="isImage(currentQuestion.mediaSrc)">
<img
class="w-full h-full object-contain max-h-[500px]"
:src="getStaticImage(currentQuestion.mediaSrc)"
alt="题目参考画面"
/>
</template>
<template v-else-if="isVideo(currentQuestion.mediaSrc)">
<Video
class="w-full h-full object-cover"
:src="currentQuestion.mediaSrc"
:poster="getVideoPoster(currentQuestion.mediaSrc)"
:muted="false"
:volume="1"
></Video>
</template>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>