forked from zhongyidian/super_video
feat: 完善课程引导与实战评价体验
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
6f8252472f
commit
1f8723aabd
122 files changed
+2595
-1325
No files matched your search
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user