forked from zhongyidian/super_video
feat: 对接各个视频数据
This commit is contained in:
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>
|
||||
Reference in new issue
Block a user