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-11 09:05:29 +08:00
1 parent 1f8723aabd
commit 6f289959be
63 files changed
+3139 -1126

No files matched your search

+190 -10
View File
@@ -1,10 +1,14 @@
<script lang="ts" setup>
import { computed, nextTick, onMounted, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
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'
@@ -16,6 +20,84 @@ 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[]>([
@@ -97,11 +179,46 @@ const navigationSteps = ref([
// 步骤导航相关状态
const showStepNavigation = ref(false)
const currentNavigationStep = ref(1) // 对应视频索引,从0开始
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,
@@ -116,13 +233,14 @@ const handleVideoChange = () => {
currentVideoIndex.value++
currentNavigationStep.value = currentVideoIndex.value
} else {
// status.value = true
// steps.value = [
// {
// id: 'step1',
// content: '恭喜你,已完成专业剖析的学习,点击【查看脚本】回顾视频脚本吧!'
// }
// ]
status.value = true
steps.value = [
{
id: 'step1',
content:
'恭喜你,已完成专业剖析的学习,点击【查看脚本】回顾视频脚本吧!',
},
]
return
}
nextTick(() => {
@@ -133,6 +251,7 @@ const handleVideoChange = () => {
const handleClose = () => {
if (key.value === 'idea') {
interview.markWorkVideoWatched('idea')
interview.setInterviewUnlockProgress(1)
router.push({
name: 'Interview',
@@ -151,11 +270,32 @@ const handleClick = () => {}
const handlePlay = () => {}
const onEnded = () => {
if (key.value === 'profession') handleVideoChange()
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',
@@ -163,8 +303,16 @@ const handleComplete = () => {
}
}
// 答题完成后的处理
const handleQuestionsComplete = () => {
markCurrentProfessionVideoWatched()
showQuiz.value = false
handleVideoChange()
}
// 步骤导航事件处理
const handleStepClick = (index: number) => {
showQuiz.value = false
currentVideoIndex.value = index
currentNavigationStep.value = index
nextTick(() => {
@@ -176,6 +324,25 @@ 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
@@ -243,6 +410,12 @@ onMounted(() => {
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
/>
<QuestionQuiz
v-if="showQuiz && key === 'profession'"
:show-question-number="false"
:questions="questions"
@complete="handleQuestionsComplete"
/>
</template>
</Video>
<div
@@ -279,9 +452,16 @@ onMounted(() => {
: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>