Files
super_video/src/views/basic-elements/camera-movement/index.vue
T
2026-09-21 10:30:13 +08:00

280 lines
7.6 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts" setup>
import { computed, nextTick, ref } from 'vue'
import { onBeforeRouteLeave, useRouter } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue'
import StepNavigation from '@/components/Guide/StepNavigation.vue'
import UnfinishedLearningPrompt from '@/components/UnfinishedLearningPrompt'
import { Video } from '@/components'
import { getPublicPath, IMAGE_PATHS, getVideoPoster } from '@/utils'
// 运镜初识 - 暂时用运镜的基本方式代替
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { useBasicElementsStore } from '@/stores/modules/basic-elements'
const router = useRouter()
const basicElementsStore = useBasicElementsStore()
const introGuideKey = 'basic-camera-movement-intro'
const showIntroGuide = ref(!basicElementsStore.guideShown[introGuideKey])
if (showIntroGuide.value) {
basicElementsStore.markGuideShown(introGuideKey)
}
const props = defineProps<{
label: string
}>()
// 受控播放开关(用于切换到下一个视频时自动播放)
const playing = ref(false)
const status = ref(false)
const showCameraMovementGuide = ref(false)
const cameraMovementGuideShown = ref(false)
const handleIntroGuideComplete = () => {
nextTick(() => {
playing.value = true
})
}
const handleCameraMovementGuideComplete = () => {
showCameraMovementGuide.value = false
cameraMovementGuideShown.value = true
nextTick(() => {
playing.value = true
})
}
// 步骤导航数据
const navigationSteps = ref([
// {
// id: 'step1',
// label: '视觉素养',
// content: '运镜初识',
// videoUrl: getPublicPath('video/new/movement/xxx.mp4'),
// type: 'planning' as const
// },
{
id: 'step2',
label: '视觉素养',
content: '正确的手机拍摄持机姿势',
videoUrl: getPublicPath(
'video/new/movement/xinshou_zhengquedeshoujipaishe.mp4',
),
type: 'planning' as const,
},
{
id: 'step3',
label: '视觉素养',
content: '运镜的基本方式',
videoUrl: getPublicPath(
'video/new/movement/xinshou_yunjingdejibenfangshi.mp4',
),
type: 'planning' as const,
},
])
// 步骤导航相关状态
const showStepNavigation = ref(false)
const showUnfinishedPrompt = ref(false)
const pendingLeavePath = ref<string | null>(null)
const allowRouteLeave = ref(false)
const currentNavigationStep = ref(0) // 对应视频索引,从0开始
// 视频列表
const currentVideoIndex = ref(0)
const getLearningVideoKey = (index: number) =>
`basic-camera-movement:${navigationSteps.value[index]?.id ?? index}`
const completedNavigationSteps = computed(() =>
navigationSteps.value.map(
(_, index) =>
basicElementsStore.learningVideosWatched[getLearningVideoKey(index)] ===
true,
),
)
const hasUnfinishedLearningVideos = computed(() =>
completedNavigationSteps.value.some((completed) => !completed),
)
const markCurrentLearningVideoWatched = () => {
basicElementsStore.markLearningVideoWatched(
getLearningVideoKey(currentVideoIndex.value),
)
}
const currentVideoWatched = computed(
() =>
basicElementsStore.learningVideosWatched[
getLearningVideoKey(currentVideoIndex.value)
] === true,
)
//按顺序播放
const currentVideo = computed(
() => navigationSteps.value[currentVideoIndex.value].videoUrl,
)
const handleVideoChange = () => {
if (currentVideoIndex.value < navigationSteps.value.length - 1) {
currentVideoIndex.value++
currentNavigationStep.value = currentVideoIndex.value
} else {
status.value = true
return
}
// 切到“运镜的基本方式”(index 1)时,先暂停并展示引导对话
if (currentVideoIndex.value === 1 && !cameraMovementGuideShown.value) {
playing.value = false
showCameraMovementGuide.value = true
return
}
playing.value = true
}
const handleClose = () => {
router.back()
}
const handlePlay = () => {}
const onEnded = () => {
markCurrentLearningVideoWatched()
handleVideoChange()
}
// 步骤导航事件处理
const handleStepClick = (index: number) => {
currentVideoIndex.value = index
currentNavigationStep.value = index
playing.value = true
}
const handleStepNavigationClose = () => {
showStepNavigation.value = false
}
const handleContinueLearning = () => {
showUnfinishedPrompt.value = false
pendingLeavePath.value = null
}
const handleConfirmLeave = () => {
if (!pendingLeavePath.value) return
allowRouteLeave.value = true
showUnfinishedPrompt.value = false
router.push(pendingLeavePath.value)
}
onBeforeRouteLeave((to) => {
if (allowRouteLeave.value || !hasUnfinishedLearningVideos.value) return true
pendingLeavePath.value = to.fullPath
showUnfinishedPrompt.value = true
return false
})
</script>
<template>
<div class="w-full h-full relative bg-white/10">
<ImgButton
class="absolute right-0 top-0 z-10"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
/>
<!-- 步骤导航按钮 -->
<button
class="absolute right-4 top-1/2 z-10 -translate-y-1/2 w-16 h-16 bg-blue-500/80 hover:bg-blue-500 rounded-full flex items-center justify-center text-white transition-all duration-200"
@click="showStepNavigation = true"
title="步骤导航"
>
<svg
width="35"
height="35"
viewBox="0 0 20 20"
fill="none"
>
<path
d="M3 4H17M3 8H17M3 12H17M3 16H17"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</button>
<Video
:src="currentVideo"
:poster="getVideoPoster(currentVideo)"
v-model="playing"
:muted="false"
:volume="1"
:controls="true"
:hideNativeControls="true"
:anchors="[]"
:seekable="currentVideoWatched"
@play="handlePlay"
@ended="onEnded"
>
<template v-slot:overlay>
<ImgButton
class="absolute right-0 top-0 z-10 pointer-events-auto"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
/>
</template>
</Video>
<!-- 步骤导航抽屉 -->
<StepNavigation
:visible="showStepNavigation"
:steps="navigationSteps"
:current-step-index="currentNavigationStep"
:completed-steps="completedNavigationSteps"
@step-click="handleStepClick"
@close="handleStepNavigationClose"
/>
<UnfinishedLearningPrompt
:visible="showUnfinishedPrompt"
@continue="handleContinueLearning"
@leave="handleConfirmLeave"
/>
<GuideSteps
:visible="showIntroGuide"
position-offset-y="85%"
@complete="handleIntroGuideComplete"
>
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_014.MP3')">
想拍出好的视频效果,画面稳定性是最基本的要求。
</GuideStepsItem>
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_015.MP3')">
接下来,我们来学习正确的持机方式和拍摄姿势。
</GuideStepsItem>
</GuideSteps>
<GuideSteps
v-if="showCameraMovementGuide"
position-offset-y="85%"
@complete="handleCameraMovementGuideComplete"
>
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_016.MP3')">
当我们将画面稳定之后,就来到了镜头语言。
</GuideStepsItem>
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_017.MP3')">
同一个场景,用不同运镜,表达就不一样。跟着我,去了解5种基础运镜吧!
</GuideStepsItem>
</GuideSteps>
</div>
</template>
<style scoped>
video {
width: 100%;
height: 100%;
/* 核心:保持视频比例,多余部分被裁剪 */
object-fit: cover;
}
</style>