forked from zhongyidian/super_video
feat: 完善课程学习与技能树体验
完善课程资源、学习进度与技能树解锁体验,统一视频播放与引导相关交互。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
1 parent
6f289959be
commit
3e258b87d9
314 files changed
+7020
-3386
No files matched your search
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user