feat: 完善课程学习与技能树体验

完善课程资源、学习进度与技能树解锁体验,统一视频播放与引导相关交互。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
zhongyidianandClaude Opus 4.7 committed 2026-05-24 10:30:18 +08:00
1 parent 6f289959be
commit 3e258b87d9
314 files changed
+7020 -3386

No files matched your search

+94 -32
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { getStaticImage } from '@/utils'
import { getStaticImage, getVideoPoster } from '@/utils'
import { Video } from '@/components'
export interface QuestionOption {
@@ -26,7 +26,7 @@ interface Props {
}
const props = withDefaults(defineProps<Props>(), {
showQuestionNumber: true
showQuestionNumber: true,
})
const emit = defineEmits<{
@@ -45,12 +45,15 @@ 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)
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)
if (!currentQuestion.value || !currentQuestion.value.correctOptionIds)
return []
return currentQuestion.value.options.filter((opt) =>
currentQuestion.value.correctOptionIds?.includes(opt.id),
)
})
@@ -69,15 +72,31 @@ watch(currentIndex, () => {
// 通过文件后缀判断媒体类型
const isVideo = (src: string): boolean => {
const videoExtensions = ['.mp4', '.mov', '.avi', '.webm', '.mkv', '.flv', '.wmv']
const videoExtensions = [
'.mp4',
'.mov',
'.avi',
'.webm',
'.mkv',
'.flv',
'.wmv',
]
const lowerSrc = src.toLowerCase()
return videoExtensions.some(ext => lowerSrc.endsWith(ext))
return videoExtensions.some((ext) => lowerSrc.endsWith(ext))
}
const isImage = (src: string): boolean => {
const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp']
const imageExtensions = [
'.png',
'.jpg',
'.jpeg',
'.gif',
'.webp',
'.svg',
'.bmp',
]
const lowerSrc = src.toLowerCase()
return imageExtensions.some(ext => lowerSrc.endsWith(ext))
return imageExtensions.some((ext) => lowerSrc.endsWith(ext))
}
const handleOptionClick = (optionId: string) => {
@@ -117,12 +136,16 @@ const handleSubmit = async () => {
const correctSet = new Set(correctIds)
// 检查是否选择了所有正确答案
const hasAllCorrect = correctIds.every(id => selectedSet.has(id))
const hasAllCorrect = correctIds.every((id) => selectedSet.has(id))
// 检查是否只选择了正确答案(没有选择错误答案)
const onlyCorrect = selectedOptionIds.value.every(id => correctSet.has(id))
const onlyCorrect = selectedOptionIds.value.every((id) =>
correctSet.has(id),
)
const isCorrect =
hasAllCorrect && onlyCorrect && selectedOptionIds.value.length === correctIds.length
hasAllCorrect &&
onlyCorrect &&
selectedOptionIds.value.length === correctIds.length
if (!isCorrect) {
// 答错时显示解析
@@ -134,7 +157,8 @@ const handleSubmit = async () => {
if (!selectedOptionId.value) return
const isGuideQuestion = q.correctOptionId === '' // 引导题判断
const isCorrect = isGuideQuestion || selectedOptionId.value === q.correctOptionId
const isCorrect =
isGuideQuestion || selectedOptionId.value === q.correctOptionId
if (!isCorrect) {
// 答错时显示解析
@@ -146,7 +170,7 @@ const handleSubmit = async () => {
// 答对时重置错误解析状态
showErrorExplanation.value = false
showCorrectFeedback.value = true
await new Promise(resolve => setTimeout(resolve, 800))
await new Promise((resolve) => setTimeout(resolve, 800))
if (currentIndex.value < total.value - 1) {
currentIndex.value += 1
@@ -160,12 +184,17 @@ const handleSubmit = async () => {
</script>
<template>
<div class="absolute inset-0 flex items-center justify-center px-20 pointer-events-none z-30">
<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
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">
@@ -179,7 +208,9 @@ const handleSubmit = async () => {
<!-- 中部:左右布局 -->
<div
:class="[
hasMedia ? 'grid grid-cols-[1.2fr,1.3fr] gap-8 items-stretch' : 'flex justify-center'
hasMedia
? 'grid grid-cols-[1.2fr,1.3fr] gap-8 items-stretch'
: 'flex justify-center',
]"
>
<!-- 左侧:题干 + 选项 -->
@@ -193,14 +224,22 @@ const handleSubmit = async () => {
</p>
</div>
<div :class="hasMoreThanFourOptions ? 'grid grid-cols-2 gap-6' : 'space-y-10'">
<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)
(
isMultiple
? selectedOptionIds.includes(opt.id)
: selectedOptionId === opt.id
)
? showErrorExplanation &&
(isMultiple
? currentQuestion.correctOptionIds?.includes(opt.id)
@@ -208,8 +247,9 @@ const handleSubmit = async () => {
? 'border-emerald-400 bg-emerald-500/30 shadow-lg shadow-emerald-500/30'
: showErrorExplanation &&
(isMultiple
? !currentQuestion.correctOptionIds?.includes(opt.id) &&
selectedOptionIds.includes(opt.id)
? !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'
@@ -219,14 +259,19 @@ const handleSubmit = async () => {
? 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'
: '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 class="font-semibold mr-2">
{{ opt.id.toUpperCase() }}.
</span>
<span>{{ opt.label }}</span>
<!-- 多选题显示复选框样式 -->
<span v-if="isMultiple" class="ml-auto text-emerald-400">
<span
v-if="isMultiple"
class="ml-auto text-emerald-400"
>
{{ selectedOptionIds.includes(opt.id) ? '✓' : '' }}
</span>
</button>
@@ -251,19 +296,29 @@ const handleSubmit = async () => {
<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 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('、')
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
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-xl font-semibold text-amber-300 mt-4">解析</div>
<div class="text-lg text-amber-100 leading-relaxed">
{{ currentQuestion.explanation }}
</div>
@@ -277,7 +332,9 @@ const handleSubmit = async () => {
type="button"
:disabled="
showCorrectFeedback ||
(isMultiple ? selectedOptionIds.length === 0 : !selectedOptionId)
(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"
@@ -288,7 +345,11 @@ const handleSubmit = async () => {
</div>
<!-- 右侧:参考图片 / 视频 -->
<div class="flex flex-col gap-3" v-if="currentQuestion.mediaSrc" :key="currentQuestion.id">
<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"
@@ -304,6 +365,7 @@ const handleSubmit = async () => {
<Video
class="w-full h-full object-cover"
:src="currentQuestion.mediaSrc"
:poster="getVideoPoster(currentQuestion.mediaSrc)"
:muted="false"
:volume="1"
></Video>