feat: 完善实战进度与技能树联动

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zhongyidianandClaude Opus 4.8 committed 2026-06-23 10:15:57 +08:00
1 parent 3e258b87d9
commit dd233e102b
194 files changed
+2915 -1481

No files matched your search

+133 -108
View File
@@ -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