forked from zhongyidian/super_video
feat: 完善实战进度与技能树联动
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
3e258b87d9
commit
dd233e102b
194 files changed
+2915
-1481
No files matched your search
@@ -36,9 +36,30 @@ const emit = defineEmits<{
|
||||
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)
|
||||
@@ -66,37 +87,96 @@ const hasMoreThanFourOptions = computed(() => {
|
||||
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 => {
|
||||
const videoExtensions = [
|
||||
'.mp4',
|
||||
'.mov',
|
||||
'.avi',
|
||||
'.webm',
|
||||
'.mkv',
|
||||
'.flv',
|
||||
'.wmv',
|
||||
]
|
||||
const lowerSrc = src.toLowerCase()
|
||||
return videoExtensions.some((ext) => lowerSrc.endsWith(ext))
|
||||
return VIDEO_EXTENSIONS.includes(getFileExtension(src))
|
||||
}
|
||||
|
||||
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))
|
||||
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) => {
|
||||
@@ -114,73 +194,41 @@ const handleOptionClick = (optionId: string) => {
|
||||
// 单选题:直接选中
|
||||
selectedOptionId.value = optionId
|
||||
}
|
||||
// 选择新选项时隐藏错误解析
|
||||
// 选择新选项时隐藏错误提示和解析
|
||||
showWrongFeedback.value = false
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 答对时重置错误解析状态
|
||||
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
|
||||
selectedOptionId.value = null
|
||||
selectedOptionIds.value = []
|
||||
} 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>
|
||||
@@ -234,46 +282,13 @@ const handleSubmit = async () => {
|
||||
: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',
|
||||
]"
|
||||
:class="getOptionClass(opt.id)"
|
||||
@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>
|
||||
|
||||
@@ -287,6 +302,16 @@ const handleSubmit = async () => {
|
||||
</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
|
||||
|
||||
Reference in new issue
Block a user