feat: 对接各个视频数据

This commit is contained in:
zhongyidian committed 2025-12-30 19:42:47 +08:00
1 parent 6103453e32
commit 6ab98c4764
339 files changed
+17316 -1531

No files matched your search

@@ -0,0 +1,304 @@
<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/40 backdrop-blur-sm 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>