feat: 完善课程引导与实战评价体验

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
zhongyidianandClaude Opus 4.7 committed 2026-05-09 09:53:06 +08:00
1 parent 6f8252472f
commit 1f8723aabd
122 files changed
+2595 -1325

No files matched your search

+46 -19
View File
@@ -1,14 +1,20 @@
<template>
<teleport to="body">
<div class="guide-steps-container" v-if="show">
<div
class="guide-steps-container"
v-if="show"
>
<SpotlightOverlay
:visible="Boolean(currentHighlightSelector)"
:visible="show"
:target-selector="currentHighlightSelector"
:padding="currentHighlightPadding"
:border-radius="currentHighlightRadius"
:highlight-mode="currentHighlightMode"
/>
<div class="guide-align" :style="alignStyle">
<div
class="guide-align"
:style="alignStyle"
>
<GuideDragon
:show-skip="currentStep < totalSteps"
:current-step="currentStep"
@@ -44,9 +50,15 @@
@continue="handleNext"
>
<!-- 支持组件形式的 content -->
<component v-if="isCurrentStepComponent" :is="currentStepContentComponent" />
<component
v-if="isCurrentStepComponent"
:is="currentStepContentComponent"
/>
<!-- 支持字符串形式的 content -->
<div v-else v-html="currentStepContent"></div>
<div
v-else
v-html="currentStepContent"
></div>
</GuideStepsItem>
</template>
</GuideDragon>
@@ -101,7 +113,7 @@ const props = withDefaults(defineProps<Props>(), {
highlightRadius: 16,
highlightMode: 'border',
positionOffsetY: '50%',
visible: true
visible: true,
})
const emit = defineEmits<Emits>()
@@ -126,7 +138,7 @@ const alignStyle = reactive<{
top: '0px',
transform: 'scale(1)',
width: '1920px',
height: '1080px'
height: '1080px',
})
const normalizeSelector = (selector?: string | null) => {
if (!selector) return null
@@ -157,21 +169,25 @@ const currentStepData = computed(() => {
return props.steps[currentStepIndex.value] || null
})
const currentStepConfig = computed(() => props.steps[currentStepIndex.value] || null)
const currentStepConfig = computed(
() => props.steps[currentStepIndex.value] || null,
)
const currentHighlightSelector = computed(() => {
return normalizeSelector(currentStepConfig.value?.highlightSelector ?? props.highlightSelector)
return normalizeSelector(
currentStepConfig.value?.highlightSelector ?? props.highlightSelector,
)
})
const currentHighlightPadding = computed(
() => currentStepConfig.value?.highlightPadding ?? props.highlightPadding
() => currentStepConfig.value?.highlightPadding ?? props.highlightPadding,
)
const currentHighlightRadius = computed(
() => currentStepConfig.value?.highlightRadius ?? props.highlightRadius
() => currentStepConfig.value?.highlightRadius ?? props.highlightRadius,
)
const currentHighlightMode = computed(
() => currentStepConfig.value?.highlightMode ?? props.highlightMode
() => currentStepConfig.value?.highlightMode ?? props.highlightMode,
)
const currentPositionOffsetY = computed(
() => currentStepConfig.value?.positionOffsetY ?? props.positionOffsetY
() => currentStepConfig.value?.positionOffsetY ?? props.positionOffsetY,
)
const currentStepComponent = computed(() => {
@@ -210,7 +226,9 @@ const currentStepContentComponent = computed(() => {
})
const handleNext = () => {
const maxSteps = useSlotMode.value ? slotSteps.value.length : props.steps.length
const maxSteps = useSlotMode.value
? slotSteps.value.length
: props.steps.length
if (maxSteps === 0) {
handleComplete()
return
@@ -234,7 +252,9 @@ const handleComplete = () => {
}
const goToStep = (index: number) => {
const maxSteps = useSlotMode.value ? slotSteps.value.length : props.steps.length
const maxSteps = useSlotMode.value
? slotSteps.value.length
: props.steps.length
if (maxSteps === 0) {
console.warn('GuideSteps goToStep: No steps available')
return
@@ -244,7 +264,12 @@ const goToStep = (index: number) => {
currentStepIndex.value = index
emit('step-change', currentStepData.value, currentStepIndex.value)
} else {
console.warn('GuideSteps goToStep: Invalid step index', index, 'max:', maxSteps - 1)
console.warn(
'GuideSteps goToStep: Invalid step index',
index,
'max:',
maxSteps - 1,
)
}
}
@@ -283,7 +308,9 @@ let autoPlayTimer: number | null = null
const startAutoPlay = () => {
if (props.autoPlayInterval > 0) {
autoPlayTimer = window.setInterval(() => {
const maxSteps = useSlotMode.value ? slotSteps.value.length : props.steps.length
const maxSteps = useSlotMode.value
? slotSteps.value.length
: props.steps.length
if (currentStepIndex.value < maxSteps - 1) {
handleNext()
} else {
@@ -317,7 +344,7 @@ onMounted(() => {
if (transform && transform !== 'none') {
const match = transform.match(/matrix\(([^)]+)\)/)
if (match) {
const parts = match[1].split(',').map(s => parseFloat(s.trim()))
const parts = match[1].split(',').map((s) => parseFloat(s.trim()))
if (!Number.isNaN(parts[0])) scale = parts[0]
}
}
@@ -343,7 +370,7 @@ defineExpose({
currentStep,
totalSteps,
startAutoPlay,
stopAutoPlay
stopAutoPlay,
})
</script>
+28 -10
View File
@@ -1,10 +1,21 @@
<template>
<div v-if="visible" class="spotlight-overlay">
<div
v-if="visible"
class="spotlight-overlay"
>
<div
v-if="!hasHighlightTarget"
class="spotlight-dim"
></div>
<!-- 渐变遮罩层 -->
<div v-if="props.highlightMode === 'gradient'" class="spotlight-mask" :style="maskStyle"></div>
<div
v-else-if="props.highlightMode === 'gradient'"
class="spotlight-mask"
:style="maskStyle"
></div>
<!-- 画框模式:使用 box-shadow 遮罩 + 边框 -->
<div
v-if="props.highlightMode === 'border' && rect.width > 0 && rect.height > 0"
v-else-if="props.highlightMode === 'border'"
class="spotlight-hole"
:style="holeStyle"
></div>
@@ -35,11 +46,14 @@ const props = withDefaults(defineProps<Props>(), {
padding: 8,
borderRadius: 16,
gradientRange: 100,
highlightMode: 'border'
highlightMode: 'border',
})
const rect = reactive({ top: 0, left: 0, width: 0, height: 0 })
const isElementReady = ref(false)
const hasHighlightTarget = computed(
() => isElementReady.value && rect.width > 0 && rect.height > 0,
)
// 重试相关变量
let retryCount = 0
@@ -148,7 +162,7 @@ const startRetry = (selector: string) => {
mutationObserver.observe(document.body, {
childList: true,
subtree: true,
attributes: false
attributes: false,
})
// 同时使用 requestAnimationFrame 轮询检查(作为备用机制)
@@ -210,7 +224,7 @@ watch(
stopRetry() // 停止之前的重试
requestAnimationFrame(measure)
},
{ immediate: true }
{ immediate: true },
)
const onResize = () => measure()
@@ -233,7 +247,7 @@ const maskStyle = computed(() => {
if (!props.visible || rect.width === 0 || rect.height === 0) {
return {
maskImage: 'none',
WebkitMaskImage: 'none'
WebkitMaskImage: 'none',
}
}
@@ -281,7 +295,7 @@ const maskStyle = computed(() => {
return {
maskImage: gradient,
WebkitMaskImage: gradient
WebkitMaskImage: gradient,
}
})
@@ -297,7 +311,7 @@ const holeStyle = computed(() => {
width: rect.width + 'px',
height: rect.height + 'px',
borderRadius: props.borderRadius + 'px',
boxShadow: `0 0 0 9999px rgba(0,0,0,0.6)`
boxShadow: `0 0 0 9999px rgba(0,0,0,0.6)`,
}
})
</script>
@@ -310,16 +324,20 @@ const holeStyle = computed(() => {
z-index: 100000;
}
.spotlight-dim,
.spotlight-mask {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.6);
transition: opacity 0.3s ease-in-out;
pointer-events: none;
}
.spotlight-mask {
mask-image: radial-gradient(ellipse, transparent 0%, black 100%);
-webkit-mask-image: radial-gradient(ellipse, transparent 0%, black 100%);
mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat;
pointer-events: none;
}
.spotlight-hole {
@@ -0,0 +1,135 @@
<script lang="ts" setup>
import { ref, watch, computed } from 'vue'
import { Overlay } from '@/components/Overlay'
import { ImgButton } from '@/components/Button'
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
interface Props {
visible: boolean
title: string
desc: string
images: string[]
compositionKey: '1' | '2' | '3'
}
const props = defineProps<Props>()
const emit = defineEmits<{ (e: 'close'): void }>()
const imageMap = {
'1': 'interview/composition/示意图-前景',
'2': 'interview/composition/示意图-留白',
'3': 'interview/composition/示意图-三角',
}
const getImage = computed(() => {
return getBackgroundImage(imageMap[props.compositionKey])
})
const getOverlayImage = (index: number) => {
const overlayMap: Record<string, string[]> = {
'1': [
'interview/composition/前景1-辅助线',
'interview/composition/前景2-辅助线',
],
'2': [
'interview/composition/留白1-辅助线',
'interview/composition/留白2-辅助线',
],
'3': [
'interview/composition/三角1-辅助线',
'interview/composition/三角2-辅助线',
],
}
const images = overlayMap[props.compositionKey] || []
return images[index] || ''
}
const showBoxLineMap = ref<Record<number, boolean>>({})
watch(
() => props.visible,
(v) => {
if (v) {
showBoxLineMap.value = {}
}
},
)
const handleToggle = (index: number) => {
showBoxLineMap.value[index] = !showBoxLineMap.value[index]
}
const show = computed(() => props.visible)
</script>
<template>
<Overlay
:visible="show"
:clickable="false"
>
<div
class="relative w-[1752px] h-[932px]"
:style="{ backgroundImage: getBackgroundImage('shooting/知识卡片底板') }"
>
<div
class="absolute top-4 left-1/2 -translate-x-1/2 text-[45px] text-white font-bold tracking-[2px] drop-shadow-[2px_2px_4px_rgba(0,0,139,0.8)]"
>
{{ props.title }}
</div>
<div
class="absolute w-full h-[38px] top-32 left-1/2 -translate-x-1/2 bg-no-repeat bg-center"
:style="{ backgroundImage: getBackgroundImage('shooting/文案1') }"
></div>
<div
class="absolute w-[191px] h-[131px] top-[30px] left-[120px] bg-no-repeat bg-center"
:style="{ backgroundImage: getImage }"
></div>
<div
class="absolute top-[calc(50%-30px)] -translate-y-1/2"
v-for="(image, index) in props.images"
:key="index"
:style="{ left: index === 0 ? '15%' : '55%' }"
>
<ImgButton
class="absolute right-6 top-6 z-10"
:image="
showBoxLineMap[index] ? 'shooting/辅助线开' : 'shooting/辅助线关'
"
@click="handleToggle(index)"
/>
<div class="relative">
<img
class="w-[560px]"
:src="getStaticImage(image)"
alt=""
/>
<div
v-if="showBoxLineMap[index]"
class="absolute inset-0 pointer-events-none w-[560px] bg-cover bg-center bg-no-repeat"
:style="{
backgroundImage: getBackgroundImage(getOverlayImage(index)),
}"
></div>
</div>
</div>
<div
class="absolute w-full px-20 bottom-32 left-1/2 -translate-x-1/2 text-[#395E66] text-3xl font-bold indent-14 leading-[50px]"
>
{{ props.desc }}
</div>
<ImgButton
class="absolute right-2 top-4 translate-x-1/2 -translate-y-1/2 z-10"
:image="IMAGE_PATHS.COMPONENTS.CLOSE"
@click="emit('close')"
/>
</div>
</Overlay>
</template>
<style scoped></style>
@@ -0,0 +1,2 @@
export { default } from './index.vue'
export type { PracticeRatingItem } from './types'
@@ -0,0 +1,117 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { ImgButton } from '@/components/Button'
import { Overlay } from '@/components/Overlay'
import { StarRating } from '@/components/Rating'
import { IMAGE_PATHS, getBackgroundImage } from '@/utils'
import type { PracticeRatingItem } from './types'
interface Props {
visible: boolean
items: PracticeRatingItem[]
backgroundImage: string
topTip?: string
bottomTip?: string
itemBackground?: string
saveButtonImage?: string
}
const props = withDefaults(defineProps<Props>(), {
itemBackground: 'vlog/practice/评分条',
saveButtonImage: 'vlog/practice/保存',
})
const emit = defineEmits<{
(e: 'close'): void
}>()
const hasTips = computed<boolean>(() =>
Boolean(props.topTip || props.bottomTip),
)
</script>
<template>
<Overlay
:visible="visible"
:clickable="false"
>
<div
class="w-[1795px] h-[987px] bg-no-repeat bg-cover p-10 pt-[100px]"
:style="{ backgroundImage: getBackgroundImage(backgroundImage) }"
>
<ImgButton
class="absolute -top-8 -right-14 z-10"
:image="IMAGE_PATHS.COMPONENTS.CLOSE"
@click="emit('close')"
/>
<div class="relative h-full overflow-y-scroll px-8">
<div class="pl-10 space-y-10 mt-10">
<p
v-if="topTip"
class="glow-text"
>
{{ topTip }}
</p>
<div
v-for="(item, index) in items"
:key="index"
class="relative w-full h-[182px] bg-no-repeat bg-cover px-12 py-3 flex justify-between"
:style="{ backgroundImage: getBackgroundImage(itemBackground) }"
>
<div class="pr-8 space-y-10">
<h4 class="text-3xl font-bold text-white mb-3">
{{ `0${index + 1}` }} {{ item.title }}
</h4>
<p class="text-3xl text-gray-800 font-bold leading-relaxed">
{{ item.description }}
</p>
</div>
<StarRating
class="absolute -bottom-5 right-10"
:score="item.score"
size="lg"
/>
</div>
<p
v-if="bottomTip"
class="glow-text"
>
{{ bottomTip }}
</p>
<div
class="w-full flex justify-center"
:class="{ 'mt-16': !hasTips }"
>
<ImgButton
:image="saveButtonImage"
@click="emit('close')"
/>
</div>
</div>
</div>
</div>
</Overlay>
</template>
<style scoped>
.glow-text {
color: white;
font-size: 2rem;
font-weight: bold;
text-shadow:
-1px -1px 0 #0066ff,
1px -1px 0 #0066ff,
-1px 1px 0 #0066ff,
1px 1px 0 #0066ff,
-2px -2px 0 #0066ff,
2px -2px 0 #0066ff,
-2px 2px 0 #0066ff,
2px 2px 0 #0066ff,
0 0 10px #0066ff,
0 0 20px #0066ff,
0 0 30px #0066ff,
0 0 40px #0066ff,
0 0 50px #0066ff;
}
</style>
@@ -0,0 +1,5 @@
export interface PracticeRatingItem {
title: string
description: string
score: number
}
+12 -2
View File
@@ -1,4 +1,9 @@
import type { ComponentOptionsMixin, DefineComponent, PublicProps, SlotsType } from 'vue'
import type {
ComponentOptionsMixin,
DefineComponent,
PublicProps,
SlotsType,
} from 'vue'
import VideoSfc from './Video.vue'
type TimeUnit = number
@@ -34,6 +39,7 @@ interface VideoProps {
playbackRate?: number
fit?: 'contain' | 'cover' | 'fill'
anchors?: VideoAnchor[]
onEnded?: () => void
}
interface VideoSlotProps {
@@ -53,7 +59,11 @@ interface VideoSlotProps {
type VideoSlots = SlotsType<{
default?: () => any
anchor?: (props: { anchor: VideoAnchor; resume: () => void; unmute: () => void }) => any
anchor?: (props: {
anchor: VideoAnchor
resume: () => void
unmute: () => void
}) => any
overlay?: (props: VideoSlotProps) => any
controls?: (props: VideoSlotProps) => any
'controls-left'?: (props: VideoSlotProps) => any