feat: 完善视频学习进度缓存
完善新手导航和三大创作模块的视频完成缓存、未完成提示与学习进度计算,确保进度按实际学习资源完成情况更新。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
1f8723aabd
commit
6f289959be
63 files changed
+3139
-1126
No files matched your search
@@ -1,13 +1,44 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { onBeforeRouteLeave, useRouter } from 'vue-router'
|
||||
import { ImgButton, Video } from '@/components'
|
||||
import QuestionQuiz, {
|
||||
type Question,
|
||||
} from '@/components/QuestionQuiz/QuestionQuiz.vue'
|
||||
import StepNavigation from '@/components/Guide/StepNavigation.vue'
|
||||
import UnfinishedLearningPrompt from '@/components/UnfinishedLearningPrompt'
|
||||
import { IMAGE_PATHS, getPublicPath, getStaticImage } from '@/utils'
|
||||
import { useInterviewStore } from '@/stores'
|
||||
|
||||
const router = useRouter()
|
||||
const interview = useInterviewStore()
|
||||
const playing = ref(false)
|
||||
const showQuiz = ref(false)
|
||||
const questions = ref<Question[]>([])
|
||||
|
||||
// 只给“移动镜头的作用”(index 2)出题
|
||||
const getQuestionsForVideo = (): Question[] => {
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
title: '',
|
||||
description: '【单选题】下面哪一种拍摄情境,最适合使用滑轨移动镜头?',
|
||||
options: [
|
||||
{ id: 'a', label: '人物正对镜头说话,背景简单,镜头静止' },
|
||||
{ id: 'b', label: '人物站在窗边,前景有栏杆或墙体,镜头缓慢横向移动' },
|
||||
{ id: 'c', label: '人物快速奔跑,摄影师跟着移动拍摄' },
|
||||
],
|
||||
correctOptionId: 'b',
|
||||
explanation:
|
||||
'滑轨镜头的作用在于稳定、克制地交代空间关系。当前景—人物—背景同时存在时,缓慢的横向移动,会让画面产生层次和叙事感。',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const showStepNavigation = ref(false)
|
||||
const showUnfinishedPrompt = ref(false)
|
||||
const pendingLeavePath = ref<string | null>(null)
|
||||
const allowRouteLeave = ref(false)
|
||||
const currentVideoIndex = ref(0)
|
||||
|
||||
const navigationSteps = ref([
|
||||
@@ -29,16 +60,41 @@ const navigationSteps = ref([
|
||||
id: 'q2-moving-shot',
|
||||
label: '中期拍摄',
|
||||
content: '移动镜头的作用',
|
||||
videoUrl: getPublicPath('video/interview/on-site/ftmk_ydjtdzy_batch.mp4'),
|
||||
type: 'shooting' as const,
|
||||
},
|
||||
{
|
||||
id: 'q2-close-up',
|
||||
label: '中期拍摄',
|
||||
content: '特写镜头的作用',
|
||||
videoUrl: getPublicPath('video/interview/on-site/ftmk_txjtdzy_batch.mp4'),
|
||||
type: 'shooting' as const,
|
||||
},
|
||||
])
|
||||
|
||||
const onsiteVideoKeys = [
|
||||
'interview-onsite-shoot-material',
|
||||
'interview-onsite-required-shots',
|
||||
'interview-onsite-moving-shot',
|
||||
'interview-onsite-close-up',
|
||||
] as const
|
||||
|
||||
const completedNavigationSteps = computed(() =>
|
||||
navigationSteps.value.map(
|
||||
(_, index) =>
|
||||
interview.professionVideosWatched[onsiteVideoKeys[index]] === true,
|
||||
),
|
||||
)
|
||||
|
||||
const hasUnfinishedLearningVideos = computed(() =>
|
||||
completedNavigationSteps.value.some((completed) => !completed),
|
||||
)
|
||||
|
||||
const markCurrentOnsiteVideoWatched = () => {
|
||||
const videoKey = onsiteVideoKeys[currentVideoIndex.value]
|
||||
if (videoKey) interview.markOnsiteVideoWatched(videoKey)
|
||||
}
|
||||
|
||||
const currentVideo = computed(
|
||||
() => navigationSteps.value[currentVideoIndex.value].videoUrl,
|
||||
)
|
||||
@@ -48,8 +104,8 @@ const back = () => {
|
||||
}
|
||||
|
||||
const handleStepClick = (index: number) => {
|
||||
showQuiz.value = false
|
||||
currentVideoIndex.value = index
|
||||
// showStepNavigation.value = false
|
||||
nextTick(() => {
|
||||
playing.value = true
|
||||
})
|
||||
@@ -59,7 +115,26 @@ const handleStepNavigationClose = () => {
|
||||
showStepNavigation.value = false
|
||||
}
|
||||
|
||||
const handleQuestionsComplete = () => {
|
||||
markCurrentOnsiteVideoWatched()
|
||||
showQuiz.value = false
|
||||
currentVideoIndex.value++
|
||||
nextTick(() => {
|
||||
playing.value = true
|
||||
})
|
||||
}
|
||||
|
||||
const handleEnded = () => {
|
||||
// “移动镜头的作用”(index 2)播完后先出题,答完再算完成并切下一个
|
||||
if (currentVideoIndex.value === 2) {
|
||||
playing.value = false
|
||||
questions.value = getQuestionsForVideo()
|
||||
showQuiz.value = true
|
||||
return
|
||||
}
|
||||
|
||||
markCurrentOnsiteVideoWatched()
|
||||
|
||||
if (currentVideoIndex.value >= navigationSteps.value.length - 1) {
|
||||
playing.value = false
|
||||
return
|
||||
@@ -70,6 +145,25 @@ const handleEnded = () => {
|
||||
playing.value = true
|
||||
})
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -108,6 +202,12 @@ const handleEnded = () => {
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<QuestionQuiz
|
||||
v-if="showQuiz"
|
||||
:show-question-number="false"
|
||||
:questions="questions"
|
||||
@complete="handleQuestionsComplete"
|
||||
/>
|
||||
</template>
|
||||
</Video>
|
||||
|
||||
@@ -116,8 +216,15 @@ const handleEnded = () => {
|
||||
:background-image="getStaticImage('work/vlog背景全屏待定')"
|
||||
:steps="navigationSteps"
|
||||
:current-step-index="currentVideoIndex"
|
||||
:completed-steps="completedNavigationSteps"
|
||||
@step-click="handleStepClick"
|
||||
@close="handleStepNavigationClose"
|
||||
/>
|
||||
|
||||
<UnfinishedLearningPrompt
|
||||
:visible="showUnfinishedPrompt"
|
||||
@continue="handleContinueLearning"
|
||||
@leave="handleConfirmLeave"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,23 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
|
||||
import { Overlay } from '@/components/Overlay'
|
||||
import { ImgButton } from '@/components/Button'
|
||||
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
|
||||
import { getBackgroundImage, getPublicPath } from '@/utils'
|
||||
import shootingtitle from '@/assets/images/shooting/标题-实地取景.png'
|
||||
import videoSrc from '/video/xinshou_jiuzhonggoutufa.mp4'
|
||||
import KnowledgeCard from '@/components/KnowledgeCard/InterviewKnowledgeCard.vue'
|
||||
import CompositionGuide from '@/components/CompositionGuide.vue'
|
||||
import { useInterviewStore } from '@/stores/modules/interview'
|
||||
|
||||
type Btn = {
|
||||
key: string
|
||||
name: string
|
||||
btnImage: string
|
||||
images: string[]
|
||||
desc: string
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const interviewStore = useInterviewStore()
|
||||
const guideKey = 'interview-shooting-intro'
|
||||
@@ -29,17 +21,7 @@ if (showIntroGuide.value) {
|
||||
|
||||
const showModal = ref(false)
|
||||
|
||||
const showModal2 = ref(false)
|
||||
const showModal3 = ref(false)
|
||||
const currentItem = ref<Btn>({
|
||||
key: '',
|
||||
name: '',
|
||||
btnImage: '',
|
||||
images: [],
|
||||
desc: '',
|
||||
})
|
||||
|
||||
const btnList = ref<Btn[]>([
|
||||
const btnList = ref([
|
||||
{
|
||||
key: '1',
|
||||
name: '前景构图',
|
||||
@@ -63,30 +45,13 @@ const btnList = ref<Btn[]>([
|
||||
},
|
||||
])
|
||||
|
||||
const handleModalClose = () => {
|
||||
showModal.value = false
|
||||
}
|
||||
|
||||
const handleClick = (item: any) => {
|
||||
currentItem.value = item
|
||||
showModal2.value = true
|
||||
}
|
||||
const currentCompositionKey = computed(() => {
|
||||
const k = currentItem.value.key
|
||||
if (k === '1' || k === '2' || k === '3') return k
|
||||
return '1'
|
||||
})
|
||||
|
||||
const handleClickA = () => {
|
||||
// 跳转设备准备
|
||||
router.push({ name: 'InterviewFilmingLocationA' })
|
||||
}
|
||||
const handleClickB = () => {
|
||||
// 跳转设备准备
|
||||
router.push({ name: 'InterviewFilmingLocationB' })
|
||||
}
|
||||
const back = () => {
|
||||
// 跳转设备准备
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
@@ -99,13 +64,13 @@ const back = () => {
|
||||
backgroundImage: getBackgroundImage('interview/shooting/实地取景底图'),
|
||||
}"
|
||||
>
|
||||
<div class="shooting-title">
|
||||
<div class="absolute top-0 left-0">
|
||||
<img
|
||||
:src="shootingtitle"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div class="shooting-return">
|
||||
<div class="absolute top-0 right-0">
|
||||
<ImgButton
|
||||
image="shooting/返回.png"
|
||||
@click="back"
|
||||
@@ -123,7 +88,7 @@ const back = () => {
|
||||
@click="handleClickB"
|
||||
/>
|
||||
</div>
|
||||
<div class="shooting-composition">
|
||||
<div class="absolute bottom-4 left-4 btn-1">
|
||||
<ImgButton
|
||||
class="mybutton"
|
||||
image="shooting/构图指南.png"
|
||||
@@ -145,116 +110,14 @@ const back = () => {
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
|
||||
<!-- 保存图片弹窗 -->
|
||||
<Overlay
|
||||
<!-- 构图指南弹窗 -->
|
||||
<CompositionGuide
|
||||
:visible="showModal"
|
||||
:clickable="false"
|
||||
:customStyle="{
|
||||
backgroundImage: getBackgroundImage('shooting/构图指南底图'),
|
||||
}"
|
||||
customClass="inset-0 bg-cover bg-center bg-no-repeat"
|
||||
contentClass="relative w-full h-full"
|
||||
>
|
||||
<!-- 返回 -->
|
||||
<ImgButton
|
||||
class="absolute right-0 top-0 z-10"
|
||||
:image="IMAGE_PATHS.COMPONENTS.BACK"
|
||||
@click="handleModalClose"
|
||||
/>
|
||||
|
||||
<!-- 构图指南 -->
|
||||
<ImgButton
|
||||
class="absolute left-0 top-0 z-10"
|
||||
image="shooting/标题-构图指南"
|
||||
/>
|
||||
|
||||
<!-- 新手导航视频 -->
|
||||
<ImgButton
|
||||
class="absolute left-28 top-[calc(50%-40px)] -translate-y-1/2"
|
||||
image="shooting/新手导航-视频回顾"
|
||||
@click="showModal3 = true"
|
||||
/>
|
||||
|
||||
<div class="absolute top-[30%] right-10">
|
||||
<ImgButton
|
||||
v-for="item in btnList"
|
||||
:key="item.key"
|
||||
:image="item.btnImage"
|
||||
@click="handleClick(item)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 知识卡片 -->
|
||||
<KnowledgeCard
|
||||
v-if="showModal2 && currentItem.key"
|
||||
:visible="showModal2"
|
||||
:title="currentItem.name"
|
||||
:desc="currentItem.desc"
|
||||
:images="currentItem.images"
|
||||
:compositionKey="currentCompositionKey"
|
||||
@close="showModal2 = false"
|
||||
/>
|
||||
<!-- 视频 -->
|
||||
<Overlay
|
||||
:visible="showModal3"
|
||||
:clickable="false"
|
||||
>
|
||||
<video
|
||||
:src="videoSrc"
|
||||
controls
|
||||
></video>
|
||||
<!-- 返回 -->
|
||||
<ImgButton
|
||||
class="absolute right-0 top-0 z-10"
|
||||
:image="IMAGE_PATHS.COMPONENTS.CLOSE"
|
||||
@click="showModal3 = false"
|
||||
/>
|
||||
</Overlay>
|
||||
</Overlay>
|
||||
bg-image="interview/shooting/访谈-构图指南底图"
|
||||
:btn-list="btnList"
|
||||
:knowledge-card-component="KnowledgeCard"
|
||||
:video-src="videoSrc"
|
||||
@close="showModal = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="less" scoped>
|
||||
.shooting-bg {
|
||||
position: absolute;
|
||||
|
||||
.shooting-title {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.shooting-return {
|
||||
position: absolute;
|
||||
right: -90%;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.shooting-left {
|
||||
position: absolute;
|
||||
left: 16%;
|
||||
top: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.shooting-right {
|
||||
position: absolute;
|
||||
right: -55%;
|
||||
top: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.shooting-composition {
|
||||
position: absolute;
|
||||
left: 24px;
|
||||
bottom: -90%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user