Files
super_video/src/components/QuestionQuiz/QuestionQuiz.vue
T
zhongyidianandClaude Opus 4.7 3e258b87d9 feat: 完善课程学习与技能树体验
完善课程资源、学习进度与技能树解锁体验,统一视频播放与引导相关交互。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 10:30:18 +08:00

390 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 showErrorExplanation = ref(false) // 是否显示错误解析
const showCorrectFeedback = ref(false)
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 = []
showErrorExplanation.value = false
showCorrectFeedback.value = false
})
// 通过文件后缀判断媒体类型
const isVideo = (src: string): boolean => {
const videoExtensions = [
'.mp4',
'.mov',
'.avi',
'.webm',
'.mkv',
'.flv',
'.wmv',
]
const lowerSrc = src.toLowerCase()
return videoExtensions.some((ext) => lowerSrc.endsWith(ext))
}
const isImage = (src: string): boolean => {
const imageExtensions = [
'.png',
'.jpg',
'.jpeg',
'.gif',
'.webp',
'.svg',
'.bmp',
]
const lowerSrc = src.toLowerCase()
return imageExtensions.some((ext) => lowerSrc.endsWith(ext))
}
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
}
// 选择新选项时隐藏错误解析
showErrorExplanation.value = false
showCorrectFeedback.value = false
}
// 交互规则:选对进入下一步,选错显示解析;最后一题选对触发 complete 事件
// 如果 correctOptionId 为空字符串,表示引导题,任意选择都可进入下一题
const handleSubmit = async () => {
const q = currentQuestion.value
if (showCorrectFeedback.value) return
if (isMultiple.value) {
// 多选题:必须选择至少一个选项
if (selectedOptionIds.value.length === 0) return
// 多选题:必须选择所有正确答案,且不能选择错误答案
const correctIds = q.correctOptionIds || []
const selectedSet = new Set(selectedOptionIds.value)
const correctSet = new Set(correctIds)
// 检查是否选择了所有正确答案
const hasAllCorrect = correctIds.every((id) => selectedSet.has(id))
// 检查是否只选择了正确答案(没有选择错误答案)
const onlyCorrect = selectedOptionIds.value.every((id) =>
correctSet.has(id),
)
const isCorrect =
hasAllCorrect &&
onlyCorrect &&
selectedOptionIds.value.length === correctIds.length
if (!isCorrect) {
// 答错时显示解析
showErrorExplanation.value = true
return
}
} else {
// 单选题
if (!selectedOptionId.value) return
const isGuideQuestion = q.correctOptionId === '' // 引导题判断
const isCorrect =
isGuideQuestion || selectedOptionId.value === q.correctOptionId
if (!isCorrect) {
// 答错时显示解析
showErrorExplanation.value = true
return
}
}
// 答对时重置错误解析状态
showErrorExplanation.value = false
showCorrectFeedback.value = true
await new Promise((resolve) => setTimeout(resolve, 800))
if (currentIndex.value < total.value - 1) {
currentIndex.value += 1
selectedOptionId.value = null
selectedOptionIds.value = []
} else {
showCorrectFeedback.value = false
emit('complete')
}
}
</script>
<template>
<div
class="absolute inset-0 flex items-center justify-center px-20 pointer-events-none z-30"
>
<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="[
(
isMultiple
? selectedOptionIds.includes(opt.id)
: selectedOptionId === opt.id
)
? showErrorExplanation &&
(isMultiple
? currentQuestion.correctOptionIds?.includes(opt.id)
: opt.id === currentQuestion.correctOptionId)
? 'border-emerald-400 bg-emerald-500/30 shadow-lg shadow-emerald-500/30'
: showErrorExplanation &&
(isMultiple
? !currentQuestion.correctOptionIds?.includes(
opt.id,
) && selectedOptionIds.includes(opt.id)
: opt.id === selectedOptionId &&
opt.id !== currentQuestion.correctOptionId)
? 'border-red-400 bg-red-500/20 shadow-lg shadow-red-500/20'
: 'border-emerald-400 bg-emerald-500/20 shadow-lg shadow-emerald-500/20'
: showErrorExplanation &&
(isMultiple
? currentQuestion.correctOptionIds?.includes(opt.id)
: opt.id === currentQuestion.correctOptionId)
? 'border-emerald-400 bg-emerald-500/20 shadow-lg shadow-emerald-500/20'
: 'border-white/15 bg-white/5 hover:bg-white/10 hover:border-white/25',
]"
@click="handleOptionClick(opt.id)"
>
<span class="font-semibold mr-2">
{{ opt.id.toUpperCase() }}.
</span>
<span>{{ opt.label }}</span>
<!-- 多选题显示复选框样式 -->
<span
v-if="isMultiple"
class="ml-auto text-emerald-400"
>
{{ selectedOptionIds.includes(opt.id) ? '✓' : '' }}
</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="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>