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
@@ -1,75 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { computeSegmentLayout } from './computeSegmentLayout'
|
||||
import type { AnchoredProgressBarProps } from './types'
|
||||
|
||||
defineOptions({
|
||||
name: 'AnchoredProgressBar',
|
||||
})
|
||||
|
||||
const props = withDefaults(defineProps<AnchoredProgressBarProps>(), {
|
||||
min: 0,
|
||||
max: 100,
|
||||
segments: () => [],
|
||||
trackClass: 'bg-black/20',
|
||||
fillClass: 'bg-[#31effa] shadow-[0_0_26px_rgba(49,239,250,0.95)]',
|
||||
segmentClass: '',
|
||||
showDividers: true,
|
||||
})
|
||||
|
||||
const clamp = (value: number, lower: number, upper: number) =>
|
||||
Math.min(Math.max(value, lower), upper)
|
||||
|
||||
const progressPercent = computed(() => {
|
||||
const range = props.max - props.min
|
||||
if (range <= 0) return 0
|
||||
return clamp(((props.progress - props.min) / range) * 100, 0, 100)
|
||||
})
|
||||
|
||||
const segmentLayout = computed(() =>
|
||||
computeSegmentLayout(props.segments, props.min, props.max),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative h-full w-full">
|
||||
<div
|
||||
class="absolute inset-0 overflow-hidden"
|
||||
:class="trackClass"
|
||||
>
|
||||
<div
|
||||
v-for="segment in segmentLayout"
|
||||
:key="segment.id"
|
||||
class="absolute top-0 h-full"
|
||||
:class="[segmentClass, segment.className]"
|
||||
:style="{
|
||||
left: `${segment.left}%`,
|
||||
width: `${segment.width}%`,
|
||||
...(segment.style ?? {}),
|
||||
}"
|
||||
></div>
|
||||
<div
|
||||
class="absolute left-0 top-0 h-full transition-all duration-700 ease-out"
|
||||
:class="fillClass"
|
||||
:style="{ width: `${progressPercent}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<template v-if="showDividers">
|
||||
<div
|
||||
v-for="segment in segmentLayout.slice(0, -1)"
|
||||
:key="`${segment.id}-divider`"
|
||||
class="pointer-events-none absolute -translate-x-1/2 rounded-[3px] bg-white"
|
||||
:style="{
|
||||
left: `${segment.left + segment.width}%`,
|
||||
top: '-15%',
|
||||
height: '130%',
|
||||
width: '8px',
|
||||
}"
|
||||
></div>
|
||||
</template>
|
||||
<slot
|
||||
:segments="segmentLayout"
|
||||
:progress="progressPercent"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { ProgressSegment, ProgressSegmentLayout } from './types'
|
||||
|
||||
const clamp = (value: number, lower: number, upper: number) =>
|
||||
Math.min(Math.max(value, lower), upper)
|
||||
|
||||
export const computeSegmentLayout = (
|
||||
segments: ProgressSegment[],
|
||||
min = 0,
|
||||
max = 100,
|
||||
): ProgressSegmentLayout[] => {
|
||||
const range = max - min
|
||||
const toPercent = (value: number) =>
|
||||
range <= 0 ? 0 : clamp(((value - min) / range) * 100, 0, 100)
|
||||
|
||||
return segments.map((segment) => {
|
||||
const left = toPercent(segment.start)
|
||||
const right = toPercent(segment.end)
|
||||
const width = Math.max(0, right - left)
|
||||
return { ...segment, left, width, center: left + width / 2 }
|
||||
})
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { default as AnchoredProgressBar } from './AnchoredProgressBar.vue'
|
||||
export { computeSegmentLayout } from './computeSegmentLayout'
|
||||
export type * from './types'
|
||||
@@ -1,25 +0,0 @@
|
||||
export type ProgressSegment = {
|
||||
id: string
|
||||
label?: string
|
||||
start: number
|
||||
end: number
|
||||
className?: string
|
||||
style?: Record<string, string>
|
||||
}
|
||||
|
||||
export type ProgressSegmentLayout = ProgressSegment & {
|
||||
left: number
|
||||
width: number
|
||||
center: number
|
||||
}
|
||||
|
||||
export type AnchoredProgressBarProps = {
|
||||
progress: number
|
||||
min?: number
|
||||
max?: number
|
||||
segments?: ProgressSegment[]
|
||||
trackClass?: string
|
||||
fillClass?: string
|
||||
segmentClass?: string
|
||||
showDividers?: boolean
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts" setup>
|
||||
const props = defineProps<{
|
||||
imageUrl: string
|
||||
fileName?: string
|
||||
}>()
|
||||
|
||||
const handleDownload = () => {
|
||||
const link = document.createElement('a')
|
||||
link.href = props.imageUrl
|
||||
link.download = props.fileName || '思路提点.png'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="download-btn absolute right-4 bottom-4 z-10 w-14 h-14 bg-white/90 hover:bg-white rounded-full flex items-center justify-center transition-all duration-200 shadow-lg hover:shadow-xl hover:scale-110"
|
||||
@click.stop="handleDownload"
|
||||
title="下载思路提点"
|
||||
>
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="text-blue-500"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line
|
||||
x1="12"
|
||||
y1="15"
|
||||
x2="12"
|
||||
y2="3"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.download-btn {
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
</style>
|
||||
@@ -183,7 +183,7 @@ const emit = defineEmits<Emits>()
|
||||
// 步骤:1=前期准备, 2=中期拍摄, 3=后期剪辑, 4=实战操作台
|
||||
// 当 completedStep >= 3(即完成第3步)时,解锁右侧完成按钮
|
||||
const canComplete = computed(() => props.completedStep >= 3)
|
||||
const isPracticeCompleted = computed(() => props.completedStep >= 4)
|
||||
const isPracticeCompleted = computed(() => props.completedStep >= 5)
|
||||
const completeButtonImage = computed(() => {
|
||||
if (!canComplete.value)
|
||||
return IMAGE_PATHS.PROGRESS_BAR.COMPLETE_BUTTON_DISABLED
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
itemClass,
|
||||
item.key == modelValue
|
||||
? 'translate-x-[-25%] brightness-100'
|
||||
: 'translate-x-[0] brightness-50',
|
||||
: item.completed
|
||||
? 'translate-x-[0] brightness-100'
|
||||
: 'translate-x-[0] brightness-50',
|
||||
'img-switch-item transition-all duration-500',
|
||||
`img-switch-item--${item.key}`,
|
||||
]"
|
||||
@@ -18,7 +20,7 @@
|
||||
<span
|
||||
v-if="item.label"
|
||||
:class="[
|
||||
'ml-6 text-white [text-shadow:5px_5px_5px_rgba(0,0,0,0.4)]',
|
||||
'ml-10 text-white [text-shadow:5px_5px_5px_rgba(0,0,0,0.4)]',
|
||||
labelClass,
|
||||
]"
|
||||
>
|
||||
@@ -44,6 +46,7 @@ interface SwitchItem {
|
||||
inactiveImage: string
|
||||
label?: string
|
||||
statusImage?: string
|
||||
completed?: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -578,7 +578,9 @@ const videoStyle = computed(() => ({
|
||||
class="w-40 h-40 pointer-events-auto rounded-full bg-black/50 backdrop-blur-sm flex items-center justify-center cursor-pointer hover:bg-black/70 transition-colors"
|
||||
>
|
||||
<svg
|
||||
class="w-22 h-22 text-white ml-1"
|
||||
class="text-white ml-1"
|
||||
width="120"
|
||||
height="120"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -10,7 +10,6 @@ export * from './Guide'
|
||||
|
||||
// 进度条组件
|
||||
export * from './ProgressBar'
|
||||
export * from './AnchoredProgressBar'
|
||||
|
||||
// 切换组件
|
||||
export * from './Switcher'
|
||||
|
||||
Reference in new issue
Block a user