Files
super_video/src/views/interview/video/index.vue
T
zhongyidianandClaude Opus 4.7 6f289959be feat: 完善视频学习进度缓存
完善新手导航和三大创作模块的视频完成缓存、未完成提示与学习进度计算,确保进度按实际学习资源完成情况更新。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:05:29 +08:00

475 lines
13 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, onMounted, ref } from 'vue'
import { onBeforeRouteLeave, useRouter, useRoute } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue'
import { GuideSteps, type GuideStep } from '@/components/Guide'
import StepNavigation from '@/components/Guide/StepNavigation.vue'
import UnfinishedLearningPrompt from '@/components/UnfinishedLearningPrompt'
import { Video } from '@/components'
import QuestionQuiz, {
type Question,
} from '@/components/QuestionQuiz/QuestionQuiz.vue'
import { getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
import { useInterviewStore } from '@/stores'
const router = useRouter()
const route = useRoute()
const interview = useInterviewStore()
// 受控播放开关(用于切换到下一个视频时自动播放)
const playing = ref(false)
const status = ref(false)
const showQuiz = ref(false) // 控制是否显示答题窗口
// 题目数据
const questions = ref<Question[]>([])
// 根据当前视频索引获取对应的题目
const getQuestionsForVideo = (index: number): Question[] => {
// 机位设置(index 0)
if (index === 0) {
return [
{
id: 1,
title: '',
description:
'【单选题】关于访谈拍摄中正面机位的中心对称构图,下列说法错误的是?',
options: [
{
id: 'a',
label: '中心对称构图能让画面更平稳,传递平等交流的访谈氛围',
},
{
id: 'b',
label: '拍摄时应让人物居中,头顶预留 3–4 指宽度,避免切头',
},
{ id: 'c', label: '为突出人物,应让人物紧贴背景,减少画面留白' },
{ id: 'd', label: '建议使用中长焦段拍摄,避免广角镜头的边缘畸变' },
],
correctOptionId: 'c',
explanation:
'中心对称构图拍摄时,需要让人物与背景保留一定距离,让画面更通透,更好地突出主体;紧贴背景会压缩画面空间,影响视觉效果,因此 C 选项错误。',
},
]
}
// 轴线原则(index 1)
if (index === 1) {
return [
{
id: 2,
title: '',
description: '【多选题】关于影视拍摄中的轴线原则,下列说法正确的有?',
options: [
{ id: 'a', label: '轴线是拍摄对象的视线、运动或关系形成的假想线' },
{
id: 'b',
label: '拍摄对话场景时,机位通常需保持在轴线同侧 180 度以内',
},
{ id: 'c', label: '越轴会导致画面空间关系混乱,出现视线错位等问题' },
{ id: 'd', label: '骑轴机位(轴线之上)不属于合规的拍摄机位' },
],
correctOptionId: '',
correctOptionIds: ['a', 'b', 'c'],
isMultiple: true,
explanation:
'轴线原则中,机位可保持在轴线同侧 180 度以内,也包含轴线之上的骑轴机位,因此 D 选项错误;A、B、C 均为轴线原则的核心内容。',
},
]
}
// 声音同步与收录(index 3)
if (index === 3) {
return [
{
id: 3,
title: '',
description: '【单选题】关于访谈拍摄的声音收录,下列做法不规范的是?',
options: [
{ id: 'a', label: '领夹麦夹在衣领,收音头朝向嘴部,距离 15–20 厘米' },
{ id: 'b', label: '拍摄前让被采访者试音,监听是否有爆音、底噪' },
{ id: 'c', label: '多机位拍摄时,开拍前拍手一次,方便后期对齐音画' },
{ id: 'd', label: '为节省时间,无需录制环境音,直接用后期音效填补' },
],
correctOptionId: 'd',
explanation:
'条件允许时,应额外录制 5–10 秒现场环境音,用于填补剪辑间隙,让音频过渡更自然;直接用后期音效会破坏现场真实感,因此 D 选项不规范。',
},
]
}
return []
}
//引导步骤
const steps = ref<GuideStep[]>([
{
id: 'step1',
audioSrc: getPublicPath('audio/py_3_006.MP3'),
content:
'刚才我们学习了访谈作品的核心拍摄技能,现在通过这张思维导图来回顾一下吧!',
},
])
const introGuideSteps = computed<GuideStep[]>(() => {
if (key.value === 'case') {
return [
{
id: 'case-intro',
audioSrc: getPublicPath('audio/py_3_003.MP3'),
content: '现在,让我们通过一段访谈案例,去看看同龄人心中的职业蓝图吧!',
},
]
}
if (key.value === 'profession') {
return [
{
id: 'profession-intro1',
audioSrc: getPublicPath('audio/py_3_004.MP3'),
content:
'看完作品后,你可能已经感受到了,一条访谈视频,好像并不是“坐着讲话”这么简单。',
},
{
id: 'profession-intro2',
audioSrc: getPublicPath('audio/py_3_005.MP3'),
content: '那么,让我们一起来拆解访谈作品的核心技能吧!',
},
]
}
return []
})
const showIntroGuide = computed(() => introGuideSteps.value.length > 0)
const handleIntroGuideDone = () => {
playing.value = true
}
// 步骤导航数据
const navigationSteps = ref([
{
id: 'step1',
label: '拍摄执行',
content: '机位设置',
videoUrl: getPublicPath('video/interview/work/ftmk_jwsz_batch.mp4'),
type: 'shooting' as const,
},
{
id: 'step2',
label: '拍摄执行',
content: '轴线原则',
videoUrl: getPublicPath('video/interview/work/ftmk_zxyz_batch.mp4'),
type: 'shooting' as const,
},
{
id: 'step3',
label: '内容策划',
content: '镜头组接逻辑',
videoUrl: getPublicPath('video/interview/work/ftmk_jtzjlj_batch.mp4'),
type: 'planning' as const,
},
{
id: 'step4',
label: '拍摄执行',
content: '声音同步与收录',
videoUrl: getPublicPath('video/interview/work/ftmk_sytbysl_batch.mp4'),
type: 'shooting' 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 professionVideoKeys = [
'interview-profession-jiweishezhi',
'interview-profession-zhouxianyuanze',
'interview-profession-jingtouzujie',
'interview-profession-shengyintongbu',
] as const
const completedNavigationSteps = computed(() =>
navigationSteps.value.map(
(_, index) =>
interview.professionVideosWatched[professionVideoKeys[index]] === true,
),
)
const hasUnfinishedLearningVideos = computed(
() =>
key.value === 'profession' &&
completedNavigationSteps.value.some((completed) => !completed),
)
const markCurrentProfessionVideoWatched = () => {
const videoKey = professionVideoKeys[currentVideoIndex.value]
if (!videoKey) return
interview.markProfessionVideoWatched(videoKey)
const isAllWatched = professionVideoKeys.every(
(key) =>
key === videoKey || interview.professionVideosWatched[key] === true,
)
if (isAllWatched) interview.markWorkVideoWatched('profession')
}
//按顺序播放
const currentVideo = computed(
() => navigationSteps.value[currentVideoIndex.value].videoUrl,
)
const resolvedSrc = computed(() => {
if (key.value === 'case') return '/video/interview/work/ft_zylt.mp4'
if (key.value === 'profession') return currentVideo.value
return ''
})
const handleVideoChange = () => {
if (currentVideoIndex.value < navigationSteps.value.length - 1) {
currentVideoIndex.value++
currentNavigationStep.value = currentVideoIndex.value
} else {
status.value = true
steps.value = [
{
id: 'step1',
content:
'恭喜你,已完成专业剖析的学习,点击【查看脚本】回顾视频脚本吧!',
},
]
return
}
nextTick(() => {
// 使用受控播放来触发下一个视频自动播放
playing.value = true
})
}
const handleClose = () => {
if (key.value === 'idea') {
interview.markWorkVideoWatched('idea')
interview.setInterviewUnlockProgress(1)
router.push({
name: 'Interview',
query: { guide: 'return' },
})
return
}
router.back()
}
const image = computed(() => route.query.image as string)
const key = computed(() => route.query.key as string)
const handleClick = () => {}
const handlePlay = () => {}
const onEnded = () => {
if (key.value === 'case') {
interview.markWorkVideoWatched('case')
return
}
if (key.value === 'profession') {
// 机位设置(index 0)、轴线原则(index 1)、声音同步与收录(index 3)先出题,答完才算完成
if (
currentVideoIndex.value === 0 ||
currentVideoIndex.value === 1 ||
currentVideoIndex.value === 3
) {
playing.value = false
questions.value = getQuestionsForVideo(currentVideoIndex.value)
showQuiz.value = true
return
}
markCurrentProfessionVideoWatched()
handleVideoChange()
}
}
const handleComplete = () => {
if (key.value === 'profession') {
interview.markWorkVideoWatched('profession')
if (!interview.isInterviewUnlock) interview.setIsInterviewUnlock(true)
router.push({
name: 'InterviewWork',
})
}
}
// 答题完成后的处理
const handleQuestionsComplete = () => {
markCurrentProfessionVideoWatched()
showQuiz.value = false
handleVideoChange()
}
// 步骤导航事件处理
const handleStepClick = (index: number) => {
showQuiz.value = false
currentVideoIndex.value = index
currentNavigationStep.value = index
nextTick(() => {
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
})
onMounted(() => {
if (key.value === 'idea') {
status.value = true
}
})
</script>
<template>
<div
class="w-full h-full relative bg-white/10"
@click="handleClick"
>
<template v-if="key !== 'case' && key !== 'profession'">
<ImgButton
class="absolute right-0 top-0 z-10"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
/>
</template>
<!-- 步骤导航按钮 -->
<button
v-if="key === 'profession'"
class="absolute right-4 top-1/2 z-10 -translate-y-1/2 w-12 h-12 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="20"
height="20"
viewBox="0 0 20 20"
fill="none"
>
<path
d="M3 4H17M3 8H17M3 12H17M3 16H17"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</button>
<div
v-if="key === 'vlog'"
:src="image"
>
<img
:src="getStaticImage('work/vlog背景全屏待定')"
alt=""
/>
</div>
<Video
v-else-if="key === 'case' || key === 'profession'"
:src="resolvedSrc"
v-model="playing"
:muted="false"
:volume="1"
:controls="true"
:hideNativeControls="true"
@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"
/>
<QuestionQuiz
v-if="showQuiz && key === 'profession'"
:show-question-number="false"
:questions="questions"
@complete="handleQuestionsComplete"
/>
</template>
</Video>
<div
v-else-if="key === 'idea'"
:src="image"
>
<img
:src="getStaticImage('interview/work/思路提点全屏')"
alt=""
/>
</div>
</div>
<!-- 进入案例赏析 / 专业剖析时显示引导 -->
<GuideSteps
v-if="showIntroGuide"
:steps="introGuideSteps"
position-offset-y="85%"
@complete="handleIntroGuideDone"
@skip="handleIntroGuideDone"
/>
<!-- 引导与高亮 -->
<GuideSteps
v-if="status"
:steps="steps"
position-offset-y="85%"
@complete="handleComplete"
/>
<!-- 步骤导航抽屉 -->
<StepNavigation
:visible="showStepNavigation && key === 'profession'"
:background-image="getStaticImage('work/vlog背景全屏待定')"
:steps="navigationSteps"
:current-step-index="currentNavigationStep"
:completed-steps="completedNavigationSteps"
@step-click="handleStepClick"
@close="handleStepNavigationClose"
/>
<UnfinishedLearningPrompt
:visible="showUnfinishedPrompt"
@continue="handleContinueLearning"
@leave="handleConfirmLeave"
/>
</template>
<style scoped>
video {
width: 100%;
height: 100%;
/* 核心:保持视频比例,多余部分被裁剪 */
object-fit: cover;
}
</style>