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

@@ -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
}
+50
View File
@@ -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>
+1 -1
View File
@@ -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
+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
+5 -2
View File
@@ -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 {
+3 -1
View File
@@ -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"
-1
View File
@@ -10,7 +10,6 @@ export * from './Guide'
// 进度条组件
export * from './ProgressBar'
export * from './AnchoredProgressBar'
// 切换组件
export * from './Switcher'