forked from zhongyidian/super_video
305 lines
11 KiB
Vue
305 lines
11 KiB
Vue
<script setup lang="ts">
|
||
import { computed, ref, watch } from 'vue'
|
||
import { getStaticImage } 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 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
|
||
})
|
||
|
||
// 通过文件后缀判断媒体类型
|
||
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 (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
|
||
}
|
||
|
||
// 交互规则:选对进入下一步,选错显示解析;最后一题选对触发 complete 事件
|
||
// 如果 correctOptionId 为空字符串,表示引导题,任意选择都可进入下一题
|
||
const handleSubmit = () => {
|
||
const q = currentQuestion.value
|
||
|
||
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
|
||
|
||
if (currentIndex.value < total.value - 1) {
|
||
currentIndex.value += 1
|
||
selectedOptionId.value = null
|
||
selectedOptionIds.value = []
|
||
} else {
|
||
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="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="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"
|
||
: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>
|