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

@@ -1,5 +1,10 @@
<script lang="tsx" setup>
import { getBackgroundImage, getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
import {
getBackgroundImage,
getPublicPath,
getStaticImage,
IMAGE_PATHS,
} from '@/utils'
import { GuideSteps } from '@/components/Guide'
import { onBeforeUnmount, ref, computed } from 'vue'
import { useRouter } from 'vue-router'
@@ -16,8 +21,8 @@ const steps = ref([
id: 'step1',
content:
'设备准备妥当后,我们就该走进真实场景,开启实地取景的环节啦,一起去捕捉那些适合拍摄的画面吧!',
audioSrc: getPublicPath('audio/py_2_013.MP3')
}
audioSrc: getPublicPath('audio/py_2_013.MP3'),
},
])
const showGuide = ref(false)
@@ -28,9 +33,14 @@ const hiddenbar = ref(false)
const currentIndex = ref(0) // 当前显示的图片索引
const selectedMenuIndex = ref(0) // 当前选中的菜单项索引
type EquipmentCategoryId = 'phone' | 'phone-stabilizer' | 'wireless-lavalier-mic'
type EquipmentCategoryId =
| 'phone'
| 'phone-stabilizer'
| 'wireless-lavalier-mic'
interface EquipmentProduct {
img: string
titleImg?: string
contentImg?: string
}
interface EquipmentCategory {
id: EquipmentCategoryId
@@ -49,9 +59,17 @@ const categories = ref<EquipmentCategory[]>([
titleImg: getStaticImage('equipment/标题-苹果.png'),
contentImg: getStaticImage('equipment/内容-苹果.png'),
products: [
{ img: getStaticImage('equipment/设备-手机1.png') },
{ img: getStaticImage('equipment/设备-手机2.png') }
]
{
img: getStaticImage('equipment/设备-手机1.png'),
titleImg: getStaticImage('equipment/标题-苹果.png'),
contentImg: getStaticImage('equipment/内容-苹果.png'),
},
{
img: getStaticImage('equipment/设备-手机2.png'),
titleImg: getStaticImage('equipment/标题-安卓小米.png'),
contentImg: getStaticImage('equipment/内容-安卓小米.png'),
},
],
},
{
id: 'phone-stabilizer',
@@ -59,7 +77,7 @@ const categories = ref<EquipmentCategory[]>([
disabled: false,
titleImg: getStaticImage('equipment/手机稳定器标题.png'),
contentImg: getStaticImage('equipment/手机稳定器图文.png'),
products: [{ img: getStaticImage('equipment/手机稳定器.png') }]
products: [{ img: getStaticImage('equipment/手机稳定器.png') }],
},
{
id: 'wireless-lavalier-mic',
@@ -67,13 +85,13 @@ const categories = ref<EquipmentCategory[]>([
disabled: false,
titleImg: getStaticImage('equipment/无线领夹麦克风标题.png'),
contentImg: getStaticImage('equipment/无线领夹麦克风图文.png'),
products: [{ img: getStaticImage('equipment/无线领夹麦克风.png') }]
}
products: [{ img: getStaticImage('equipment/无线领夹麦克风.png') }],
},
])
categories.value = categories.value.map(c => ({
categories.value = categories.value.map((c) => ({
...c,
disabled: (c.products?.length ?? 0) === 0
disabled: (c.products?.length ?? 0) === 0,
}))
// 默认选中的菜单也算作已查看
@@ -83,9 +101,11 @@ if (categories.value[0] && !categories.value[0].disabled) {
const maybeTriggerGuide = () => {
if (guideTriggered.value) return
const enabledIds = categories.value.filter(c => !c.disabled).map(c => c.id)
const enabledIds = categories.value
.filter((c) => !c.disabled)
.map((c) => c.id)
if (enabledIds.length === 0) return
const allViewed = enabledIds.every(id => viewedMenuKeys.value.has(id))
const allViewed = enabledIds.every((id) => viewedMenuKeys.value.has(id))
if (!allViewed) return
guideTriggered.value = true
guideTimer.value = setTimeout(() => {
@@ -102,17 +122,26 @@ onBeforeUnmount(() => {
})
const currentCategory = computed(
() => categories.value[selectedMenuIndex.value] ?? categories.value[0]
() => categories.value[selectedMenuIndex.value] ?? categories.value[0],
)
const currentProducts = computed(() => currentCategory.value?.products ?? [])
const currentTitleImg = computed(() => currentCategory.value?.titleImg ?? '')
const currentContentImg = computed(() => currentCategory.value?.contentImg ?? '')
const currentProduct = computed(() => currentProducts.value[currentIndex.value])
const currentTitleImg = computed(
() => currentProduct.value?.titleImg ?? currentCategory.value?.titleImg ?? '',
)
const currentContentImg = computed(
() =>
currentProduct.value?.contentImg ?? currentCategory.value?.contentImg ?? '',
)
const hiddenbarclick = () => {
hiddenbar.value = !hiddenbar.value
}
// 选择菜单项
const selectMenuItem = (index: number, item: { id: string; name: string; disabled: boolean }) => {
const selectMenuItem = (
index: number,
item: { id: string; name: string; disabled: boolean },
) => {
if (item.disabled) {
return
}
@@ -145,9 +174,16 @@ const prevPhone = () => {
></div>
<div class="equipment-bar">
<img src="@/assets/images/equipment/侧边导航栏.png" alt="" :hidden="hiddenbar" />
<img
src="@/assets/images/equipment/侧边导航栏.png"
alt=""
:hidden="hiddenbar"
/>
<!-- 内容列表 -->
<div class="equipment-item" :hidden="hiddenbar">
<div
class="equipment-item"
:hidden="hiddenbar"
>
<div class="menu-list">
<div
v-for="(item, index) in categories"
@@ -155,7 +191,7 @@ const prevPhone = () => {
class="menu-item"
:class="{
'menu-item--active': selectedMenuIndex === index,
'menu-item--disabled': item.disabled
'menu-item--disabled': item.disabled,
}"
@click="selectMenuItem(index, item)"
>
@@ -164,7 +200,10 @@ const prevPhone = () => {
v-if="selectedMenuIndex === index && !item.disabled"
></div>
<span class="menu-item__text">{{ item.name }}</span>
<div class="menu-item__status" v-if="item.disabled">
<div
class="menu-item__status"
v-if="item.disabled"
>
<!-- <span class="status-text">未解锁</span> -->
</div>
</div>
@@ -173,10 +212,16 @@ const prevPhone = () => {
<!-- 设备准备按钮 -->
<div class="equipment-bt">
<img src="@/assets/images/equipment/按钮选中-设备准备.png" alt="" />
<img
src="@/assets/images/equipment/按钮选中-设备准备.png"
alt=""
/>
</div>
<div
:class="['cursor-pointer', hiddenbar ? 'equipment-unfold1' : 'equipment-unfold2']"
:class="[
'cursor-pointer',
hiddenbar ? 'equipment-unfold1' : 'equipment-unfold2',
]"
@click="hiddenbarclick()"
>
<img
@@ -196,36 +241,61 @@ const prevPhone = () => {
@click="prevPhone"
:style="{ visibility: currentIndex > 0 ? 'visible' : 'hidden' }"
>
<img src="@/assets/images/equipment/按钮-左滑.png" alt="" />
<img
src="@/assets/images/equipment/按钮-左滑.png"
alt=""
/>
</div>
<div class="equipment-phoneImg">
<img :src="currentProducts[currentIndex]?.img" alt="" />
<img
:src="currentProducts[currentIndex]?.img"
alt=""
/>
</div>
<div
class="equipment-rightbutton"
@click="nextPhone"
:style="{ visibility: currentIndex < currentProducts.length - 1 ? 'visible' : 'hidden' }"
:style="{
visibility:
currentIndex < currentProducts.length - 1 ? 'visible' : 'hidden',
}"
>
<img src="@/assets/images/equipment/按钮-右滑.png" alt="" />
<img
src="@/assets/images/equipment/按钮-右滑.png"
alt=""
/>
</div>
</div>
<div class="equipment-purpose">
<div class="plate">
<img src="@/assets/images/equipment/浮空屏.png" alt="" />
<img
src="@/assets/images/equipment/浮空屏.png"
alt=""
/>
</div>
<div class="title">
<img :src="currentTitleImg" alt="" />
<img
:src="currentTitleImg"
alt=""
/>
</div>
<div class="plate-content">
<div class="text">
<img :src="currentContentImg" alt="" />
<img
:src="currentContentImg"
alt=""
/>
</div>
</div>
</div>
</div>
<ImgButton class="absolute right-0 top-0" :image="IMAGE_PATHS.COMPONENTS.BACK" @click="back" />
<ImgButton
class="absolute right-0 top-0"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="back"
/>
<!-- 调试按钮组 -->
<!-- <div class="debug-controls">
<button @click="enableAllMenuItems" class="debug-btn">启用全部</button>
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import { useRouter } from 'vue-router'
import { onBeforeRouteLeave, useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import StepNavigation from '@/components/Guide/StepNavigation.vue'
import UnfinishedLearningPrompt from '@/components/UnfinishedLearningPrompt'
import { Video } from '@/components'
import { getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
import { useVlogStore } from '@/stores/modules/vlog'
@@ -18,25 +19,28 @@ if (showIntroGuide.value) {
vlogStore.markGuideShown(guideKey)
}
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([
{
id: 'opening-shot',
id: 'vlog-onsite-opening-shot',
label: '拍摄执行',
content: '开场建立',
videoUrl: getPublicPath('video/vlog/on-site/Vlogmk_kcjl_batch.mp4'),
type: 'shooting' as const,
},
{
id: 'lens-language',
id: 'vlog-onsite-lens-language',
label: '拍摄执行',
content: '镜头语言',
videoUrl: getPublicPath('video/vlog/on-site/Vlogmk_jtyy_batch.mp4'),
type: 'shooting' as const,
},
{
id: 'motion-capture',
id: 'vlog-onsite-motion-capture',
label: '拍摄执行',
content: '动态跟拍与抓拍',
videoUrl: getPublicPath('video/vlog/on-site/Vlogmk_dtgpyzp_batch.mp4'),
@@ -44,6 +48,21 @@ const navigationSteps = ref([
},
])
const completedNavigationSteps = computed(() =>
navigationSteps.value.map(
(step) => vlogStore.professionVideosWatched[step.id] === true,
),
)
const hasUnfinishedLearningVideos = computed(() =>
completedNavigationSteps.value.some((completed) => !completed),
)
const markCurrentVideoWatched = () => {
const videoKey = navigationSteps.value[currentVideoIndex.value]?.id
if (videoKey) vlogStore.markOnsiteVideoWatched(videoKey)
}
const currentVideo = computed(
() => navigationSteps.value[currentVideoIndex.value].videoUrl,
)
@@ -64,6 +83,8 @@ const handleStepNavigationClose = () => {
}
const handleEnded = () => {
markCurrentVideoWatched()
if (currentVideoIndex.value >= navigationSteps.value.length - 1) {
playing.value = false
return
@@ -80,6 +101,25 @@ const handleIntroGuideComplete = () => {
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>
@@ -127,10 +167,17 @@ const handleIntroGuideComplete = () => {
: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"
/>
<GuideSteps
v-if="showIntroGuide"
position-offset-y="85%"
@@ -1,24 +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 { Video } from '@/components/Video'
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/KnowledgeCard.vue'
import CompositionGuide from '@/components/CompositionGuide.vue'
import { useVlogStore } from '@/stores/modules/vlog'
type Btn = {
key: string
name: string
btnImage: string
images: string[]
desc: string
}
const router = useRouter()
const vlogStore = useVlogStore()
const guideKey = 'vlog-shooting-intro'
@@ -30,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: '居中构图',
@@ -64,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: 'VlogFilmingLocationA' })
}
const handleClickB = () => {
// 跳转设备准备
router.push({ name: 'VlogFilmingLocationB' })
}
const back = () => {
// 跳转设备准备
router.back()
}
</script>
@@ -100,18 +64,20 @@ const back = () => {
backgroundImage: getBackgroundImage('vlog/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"
/>
</div>
<div class="absolute left-[12%] top-1/2 -translate-y-[calc(50%-70px)]">
<ImgButton
image="vlog/shooting/热带植物馆-入口.png"
@@ -124,7 +90,7 @@ const back = () => {
@click="handleClickB"
/>
</div>
<div class="shooting-composition btn-1">
<div class="absolute bottom-4 left-4 btn-1">
<ImgButton
image="shooting/构图指南.png"
@click="showModal = true"
@@ -145,118 +111,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
:volume="1"
:muted="false"
></Video>
<!-- 返回 -->
<ImgButton
class="absolute right-0 top-0 z-10"
:image="IMAGE_PATHS.COMPONENTS.CLOSE"
@click="showModal3 = false"
/>
</Overlay>
</Overlay>
bg-image="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>