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,9 +1,10 @@
<script lang="ts" setup>
import { computed, nextTick, ref } from 'vue'
import { useRouter } from 'vue-router'
import { onBeforeRouteLeave, useRouter } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import StepNavigation from '@/components/Guide/StepNavigation.vue'
import UnfinishedLearningPrompt from '@/components/UnfinishedLearningPrompt'
import { Video } from '@/components'
import QuestionQuiz, {
type Question,
@@ -79,11 +80,35 @@ const navigationSteps = ref(rawSteps)
// 步骤导航相关状态
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-shooting-skills:${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 currentVideo = computed(
() => navigationSteps.value[currentVideoIndex.value].videoUrl,
@@ -101,7 +126,7 @@ const getQuestionsForVideo = (index: number): Question[] => {
id: 1,
title: '',
description:
'(单选)如果你想让观众“看清环境、理解空间关系”,更适合用哪种镜头?',
'【单选题】如果你想让观众“看清环境、理解空间关系”,更适合用哪种镜头?',
options: [
{
id: 'a',
@@ -129,7 +154,7 @@ const getQuestionsForVideo = (index: number): Question[] => {
id: 2,
title: '',
description:
'(多选题)下面这些拍摄场景中,哪些更适合使用"慢动作"来表现?',
'【多选题】下面这些拍摄场景中,哪些更适合使用"慢动作"来表现?',
options: [
{
id: 'a',
@@ -163,7 +188,8 @@ const getQuestionsForVideo = (index: number): Question[] => {
{
id: 3,
title: '',
description: '(单选)你觉得下面哪句话,更接近“新手拍视频的正确思路”?',
description:
'【单选题】你觉得下面哪句话,更接近“新手拍视频的正确思路”?',
options: [
{
id: 'a',
@@ -209,6 +235,8 @@ const handleClose = () => {
const handlePlay = () => {}
const onEnded = () => {
markCurrentLearningVideoWatched()
// 在指定视频结束时弹出答题
if (
currentVideoIndex.value === 1 ||
@@ -250,6 +278,25 @@ const handleQuestionsComplete = () => {
// 答题完成后继续播放下一个视频
handleVideoChange()
}
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>
@@ -313,10 +360,17 @@ const handleQuestionsComplete = () => {
: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"
/>
<GuideSteps
:visible="showIntroGuide"
position-offset-y="85%"
@@ -326,7 +380,7 @@ const handleQuestionsComplete = () => {
欢迎来到基础拍摄训练区!
</GuideStepsItem>
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_019.MP3')">
我会带你依次认识手机摄像头、看懂景别、用好慢动作与延时。
我会带你认识手机摄像头、看懂景别、了解慢动作与延迟摄影。
</GuideStepsItem>
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_020.MP3')">
还会教你拍出更稳的微距和更亮的夜景。
@@ -1,8 +1,9 @@
<script lang="ts" setup>
import { computed, nextTick, ref } from 'vue'
import { useRouter } from 'vue-router'
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, getStaticImage, IMAGE_PATHS } from '@/utils'
// 运镜初识 - 暂时用运镜的基本方式代替
@@ -73,11 +74,35 @@ const navigationSteps = ref([
// 步骤导航相关状态
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 currentVideo = computed(
() => navigationSteps.value[currentVideoIndex.value].videoUrl,
@@ -113,6 +138,7 @@ const label = computed(() => props.label as string)
const handlePlay = () => {}
const onEnded = () => {
markCurrentLearningVideoWatched()
handleVideoChange()
}
@@ -128,6 +154,25 @@ const handleStepClick = (index: number) => {
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>
@@ -184,10 +229,17 @@ const handleStepNavigationClose = () => {
: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"
/>
<GuideSteps
:visible="showIntroGuide"
position-offset-y="85%"
+53 -1
View File
@@ -1,8 +1,9 @@
<script lang="ts" setup>
import { computed, nextTick, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
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, getStaticImage, IMAGE_PATHS } from '@/utils'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
@@ -45,11 +46,35 @@ const navigationSteps = ref([
// 步骤导航相关状态
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-composition:${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 currentVideo = computed(
() => navigationSteps.value[currentVideoIndex.value].videoUrl,
@@ -77,6 +102,7 @@ const label = computed(() => props.label as string)
const handlePlay = () => {}
const onEnded = () => {
markCurrentLearningVideoWatched()
handleVideoChange()
}
@@ -101,6 +127,25 @@ const handleIntroGuideComplete = () => {
})
}
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(() => {})
</script>
@@ -158,10 +203,17 @@ 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"
/>
<GuideSteps
:visible="showIntroGuide"
:highlight-selector="guideStepIndex === 3 ? '.step-navigation-btn' : ''"
+55 -2
View File
@@ -1,8 +1,9 @@
<script lang="ts" setup>
import { computed, nextTick, ref } from 'vue'
import { useRouter } from 'vue-router'
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 QuestionQuiz, {
type Question,
@@ -76,11 +77,35 @@ const navigationSteps = ref([
// 步骤导航相关状态
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-light:${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 currentVideo = computed(
() => navigationSteps.value[currentVideoIndex.value].videoUrl,
@@ -98,7 +123,7 @@ const getQuestionsForVideo = (index: number): Question[] => {
id: 1,
title: '',
description:
'(单选)同一条街道,在不同时间拍摄,哪一张画面更适合表现"温暖、舒服的日常感"?',
'【单选题】同一条街道,在不同时间拍摄,哪一张画面更适合表现"温暖、舒服的日常感"?',
options: [
{
id: 'a',
@@ -153,6 +178,8 @@ const handlePlay = () => {
}
const onEnded = () => {
markCurrentLearningVideoWatched()
// 检查是否是"拍摄的黄金时间"(index 2)
if (currentVideoIndex.value === 2) {
// 暂停播放并显示答题窗口
@@ -186,6 +213,25 @@ const handleQuestionsComplete = () => {
// 答题完成后继续播放下一个视频
handleVideoChange()
}
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>
@@ -248,10 +294,17 @@ const handleQuestionsComplete = () => {
: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"
/>
<!-- 引导与高亮 -->
<GuideSteps
@@ -1,9 +1,10 @@
<script lang="ts" setup>
import { computed, nextTick, ref } from 'vue'
import { useRouter } from 'vue-router'
import { onBeforeRouteLeave, useRouter } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue'
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 { useBasicElementsStore } from '@/stores/modules/basic-elements'
@@ -113,11 +114,36 @@ const navigationSteps = ref(rawSteps)
// 步骤导航相关状态
const showStepNavigation = ref(false)
const showUnfinishedPrompt = ref(false)
const showCompletionGuide = 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-short-video-editing-skills:${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 currentVideo = computed(() => rawSteps[currentVideoIndex.value].videoUrl)
@@ -127,6 +153,7 @@ const handleVideoChange = () => {
currentNavigationStep.value = currentVideoIndex.value
} else {
status.value = true
showCompletionGuide.value = true
return
}
nextTick(() => {
@@ -142,6 +169,7 @@ const handleClose = () => {
const handlePlay = () => {}
const onEnded = () => {
markCurrentLearningVideoWatched()
handleVideoChange()
}
@@ -163,6 +191,29 @@ const handleIntroGuideComplete = () => {
playing.value = true
})
}
const handleCompletionGuideComplete = () => {
showCompletionGuide.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>
@@ -220,10 +271,17 @@ const handleIntroGuideComplete = () => {
: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"
/>
<GuideSteps
:visible="showIntroGuide"
position-offset-y="85%"
@@ -242,6 +300,16 @@ const handleIntroGuideComplete = () => {
准备好了吗?让零散的素材在你的手中拼接成精彩作品!
</GuideStepsItem>
</GuideSteps>
<GuideSteps
:visible="showCompletionGuide"
position-offset-y="85%"
@complete="handleCompletionGuideComplete"
>
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_025.MP3')">
新手导航模块完成啦,快去首页解锁进阶任务吧!
</GuideStepsItem>
</GuideSteps>
</div>
</template>
+72 -103
View File
@@ -1,31 +1,68 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { getBackgroundImage, getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
import {
getBackgroundImage,
getPublicPath,
getStaticImage,
IMAGE_PATHS,
} from '@/utils'
import { ImgButton } from '@/components/Button'
import { GuideSteps, type GuideStep } from '@/components/Guide'
import { Video } from '@/components'
import { useFilmStore } from '@/stores'
const router = useRouter()
const film = useFilmStore()
const completionIcon = (completed: boolean) =>
getStaticImage(`midterm/${completed ? '完成' : '未完成'}.png`)
const editingItems = computed(() => [
{
image: 'film/after-editing/剪辑初始',
videoKey: 'start',
onClick: goStart,
class:
'absolute left-[0px] bottom-[42%] translate-y-1/2 bg-cover bg-center bg-no-repeat',
},
{
image: 'film/after-editing/节奏型剪辑',
videoKey: 'rhythm',
onClick: goRhythm,
class: 'absolute right-[0px] top-[160px] bg-cover bg-center bg-no-repeat',
},
{
image: 'film/after-editing/声音转场',
videoKey: 'soundTransition',
onClick: goSoundTransition,
class: 'absolute right-[42px] top-[410px] bg-cover bg-center bg-no-repeat',
},
{
image: 'film/after-editing/快切',
videoKey: 'cut',
onClick: goCut,
class: 'absolute right-[520px] top-[580px] bg-cover bg-center bg-no-repeat',
},
{
image: 'film/after-editing/关联剪辑',
videoKey: 'association',
onClick: goAssociation,
class:
'absolute right-[42px] bottom-[270px] bg-cover bg-center bg-no-repeat',
},
{
image: 'film/after-editing/连续剪辑',
videoKey: 'continuity',
onClick: goContinuity,
class:
'absolute right-[42px] bottom-[16px] bg-cover bg-center bg-no-repeat',
},
])
const handleClose = () => {
router.back()
}
// 剪辑介绍视频 URL(暂时用其他视频代替)
const introVideoUrl = getPublicPath('video/on-site/xianchangpaishe_yanshipaishe.mp4')
// 视频播放状态
const videoEnded = ref(true)
const videoPlaying = ref(false)
// 视频播放完成
const handleVideoEnded = () => {
videoEnded.value = true
}
const goStart = () => {
router.push({ name: 'FilmEditingStart' })
}
@@ -56,20 +93,20 @@ const editingCompleteSteps: GuideStep[] = [
id: 'editing-complete-step1',
content:
'刚才我们提到的剪辑方式,并不是孤立存在的技巧,而是针对不同问题的解决方案。所以,理解剪辑逻辑才是最重要的!',
audioSrc: getPublicPath('audio/py_4_018.MP3')
}
audioSrc: getPublicPath('audio/py_4_018.MP3'),
},
]
const showEditingCompleteGuide = ref(false)
watch(
() => film.allEditingVideosWatched,
watched => {
(watched) => {
if (watched && film.isGuideFilmEditingComplete) {
showEditingCompleteGuide.value = true
}
},
{ immediate: true }
{ immediate: true },
)
const handleEditingCompleteGuideDone = () => {
@@ -83,7 +120,9 @@ const handleEditingCompleteGuideDone = () => {
<!-- 背景 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: getBackgroundImage('film/after-editing/剪辑底图') }"
:style="{
backgroundImage: getBackgroundImage('film/after-editing/剪辑底图'),
}"
></div>
<!-- 标题 -->
@@ -93,90 +132,20 @@ const handleEditingCompleteGuideDone = () => {
alt=""
/>
<!-- 视频播放区域 -->
<!-- <div v-if="!videoEnded" class="absolute inset-0 flex items-center justify-center">
<div class="w-full h-full bg-black/40 backdrop-blur-md rounded-3xl border border-white/20">
<Video
class="w-full h-full"
:src="introVideoUrl"
v-model="videoPlaying"
:muted="false"
:volume="1"
:controls="true"
:hideNativeControls="true"
:autoplay="true"
:show-center-play-icon="true"
@ended="handleVideoEnded"
/>
</div>
</div> -->
<!-- 内容卡片 + 按钮 -->
<!-- <div
v-if="videoEnded"
class="absolute inset-0 flex items-center justify-center px-16 pointer-events-none"
<div
v-for="item in editingItems"
:key="item.videoKey"
:class="item.class"
>
<div
class="max-w-screen-2xl w-full bg-black/35 rounded-3xl border border-white/15 p-16 space-y-10 text-white pointer-events-auto"
>
<div class="flex items-baseline justify-between gap-6">
<h1 class="text-5xl font-extrabold tracking-wide">剪辑</h1>
</div>
<p class="text-2xl leading-8 opacity-90">
选择下面任意一个练习模块,进入对应的剪辑场景,按提示完成小任务,逐步建立自己的剪辑手感和创作节奏。
</p>
<div class="grid grid-cols-3 gap-10">
<button type="button" class="onsite-btn" @click="goStart">
<span class="onsite-btn-title">剪辑从哪里开始</span>
<span class="onsite-btn-desc">通过不同的剪辑起点,营造不同的叙事效果。</span>
</button>
<button type="button" class="onsite-btn" @click="goRhythm">
<span class="onsite-btn-title">节奏型剪辑</span>
<span class="onsite-btn-desc">通过剪辑节奏控制画面节奏,营造紧张感或舒缓感。</span>
</button>
<button type="button" class="onsite-btn" @click="goSoundTransition">
<span class="onsite-btn-title">声音转场</span>
<span class="onsite-btn-desc">利用相似声音连接不同场景,实现自然的转场效果。</span>
</button>
<button type="button" class="onsite-btn" @click="goCut">
<span class="onsite-btn-title">快切</span>
<span class="onsite-btn-desc">通过精准的切块技巧,提升画面的节奏感和连贯性。</span>
</button>
<button type="button" class="onsite-btn" @click="goAssociation">
<span class="onsite-btn-title">关联剪辑</span>
<span class="onsite-btn-desc">通过画面元素的关联性,建立场景之间的逻辑联系。</span>
</button>
<button type="button" class="onsite-btn" @click="goContinuity">
<span class="onsite-btn-title">连续剪辑</span>
<span class="onsite-btn-desc">保持动作和空间的连续性,让画面过渡更加自然流畅。</span>
</button>
</div>
</div>
</div> -->
<div class="absolute left-[0px] bottom-[42%] translate-y-1/2 bg-cover bg-center bg-no-repeat">
<ImgButton :image="'film/after-editing/剪辑初始'" @click="goStart" />
</div>
<div class="absolute right-[0px] top-[160px] bg-cover bg-center bg-no-repeat">
<ImgButton :image="'film/after-editing/节奏型剪辑'" @click="goRhythm" />
</div>
<div class="absolute right-[42px] top-[410px] bg-cover bg-center bg-no-repeat">
<ImgButton :image="'film/after-editing/声音转场'" @click="goSoundTransition" />
</div>
<div class="absolute right-[520px] top-[580px] bg-cover bg-center bg-no-repeat">
<ImgButton :image="'film/after-editing/快切'" @click="goCut" />
</div>
<div class="absolute right-[42px] bottom-[270px] bg-cover bg-center bg-no-repeat">
<ImgButton :image="'film/after-editing/关联剪辑'" @click="goAssociation" />
</div>
<div class="absolute right-[42px] bottom-[16px] bg-cover bg-center bg-no-repeat">
<ImgButton :image="'film/after-editing/连续剪辑'" @click="goContinuity" />
<ImgButton
:image="item.image"
@click="item.onClick"
/>
<img
class="pointer-events-none absolute left-0 top-0 z-10 h-12 w-12"
:src="completionIcon(film.editingVideosWatched[item.videoKey] === true)"
alt=""
/>
</div>
<!-- 关闭 -->
+50 -32
View File
@@ -16,61 +16,61 @@ const category = ref([
{
label: '前期准备',
value: 1,
icon: getStaticImage('midterm/准备图标.png')
icon: getStaticImage('midterm/准备图标.png'),
},
{
label: '中期拍摄',
value: 2,
icon: getStaticImage('midterm/拍摄图标.png')
icon: getStaticImage('midterm/拍摄图标.png'),
},
{
label: '后期剪辑',
value: 3,
icon: getStaticImage('midterm/剪辑图标.png')
}
icon: getStaticImage('midterm/剪辑图标.png'),
},
])
const menuItems = ref([
{
label: '资源筹备',
routeName: 'FilmResource',
parentValue: 1,
disabled: false
disabled: false,
},
{
label: '创意构想',
routeName: 'FilmCreative',
parentValue: 1,
disabled: false
disabled: false,
},
{
label: '脚本撰写',
routeName: 'FilmScriptWriting',
parentValue: 1,
disabled: false
disabled: false,
},
{
label: '分镜制作',
routeName: 'FilmStoryboard',
parentValue: 1,
disabled: false
disabled: false,
},
{
label: '设备准备',
routeName: 'FilmMidtermEquipment',
parentValue: 2,
disabled: false
disabled: false,
},
{
label: '实地取景',
routeName: 'FilmMidtermShooting',
parentValue: 2,
disabled: false
disabled: false,
},
{
label: '现场拍摄',
routeName: 'FilmOnsiteShooting',
parentValue: 2,
disabled: false
disabled: false,
},
// {
// label: '素材管理',
@@ -82,27 +82,27 @@ const menuItems = ref([
label: '剪辑',
routeName: 'FilmEditing',
parentValue: 3,
disabled: false
disabled: false,
},
{
label: '调色',
routeName: 'FilmColorPractice',
parentValue: 3,
disabled: false
disabled: false,
},
//音效
{
label: '音效',
routeName: 'FilmSoundEffect',
parentValue: 3,
disabled: false
disabled: false,
},
{
label: '导出发布',
routeName: 'FilmExportPublish',
parentValue: 3,
disabled: false
}
disabled: false,
},
])
const props = defineProps<{
@@ -112,9 +112,8 @@ const props = defineProps<{
const router = useRouter()
const film = useFilmStore()
const isViewed = (routeName: string) => {
return film.viewed?.[routeName] === true
}
const isItemCompleted = (routeName: string): boolean =>
film.isRouteViewed(routeName)
const currentParentValue = computed<number>(() => {
return props.currentStep
@@ -123,7 +122,9 @@ const currentParentValue = computed<number>(() => {
const currentItemValue = ref<string>('') // 当前选中的子级菜单项
const getMenuItems = computed(() => {
return menuItems.value.filter(item => item.parentValue === currentParentValue.value)
return menuItems.value.filter(
(item) => item.parentValue === currentParentValue.value,
)
})
const handleItemClick = (item: { routeName: string; disabled: boolean }) => {
@@ -134,13 +135,20 @@ const handleItemClick = (item: { routeName: string; disabled: boolean }) => {
</script>
<template>
<div class="absolute left-20 top-0 bottom-0 w-[520px] pt-28" v-if="currentStep">
<div
class="absolute left-20 top-0 bottom-0 w-[520px] pt-28"
v-if="currentStep"
>
<!-- 分类名称 -->
<div class="absolute top-3 left-24 flex items-center space-x-2">
<span class="text-4xl text-[#fbbf24] font-black">
{{ category[currentParentValue - 1].label }}
</span>
<img class="filter-blue" :src="category[currentParentValue - 1].icon" alt="" />
<img
class="filter-blue"
:src="category[currentParentValue - 1].icon"
alt=""
/>
</div>
<!-- 背景图片 -->
<div
@@ -148,12 +156,17 @@ const handleItemClick = (item: { routeName: string; disabled: boolean }) => {
:style="{ backgroundImage: getBackgroundImage('components/menu-bg.png') }"
></div>
<div class="flex items-center flex-col pl-[60px]">
<template v-for="(item, index) in getMenuItems" :key="item.routeName">
<template
v-for="(item, index) in getMenuItems"
:key="item.routeName"
>
<div
class="relative w-[373px] h-[143px] pl-14 flex items-center justify-center group text-4xl font-black transition-colors"
:class="[
currentItemValue === item.routeName ? 'text-blue-400' : 'text-white',
item.disabled ? 'opacity-20 cursor-not-allowed' : 'cursor-pointer'
currentItemValue === item.routeName
? 'text-blue-400'
: 'text-white',
item.disabled ? 'opacity-20 cursor-not-allowed' : 'cursor-pointer',
]"
:style="{ marginLeft: -index * 40 + 'px' }"
@click="handleItemClick(item)"
@@ -161,16 +174,21 @@ const handleItemClick = (item: { routeName: string; disabled: boolean }) => {
<div
v-if="!item.disabled"
class="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-0 group-hover:opacity-100 transition-opacity duration-300"
:style="{ backgroundImage: getBackgroundImage('components/menu-hover.png') }"
:style="{
backgroundImage: getBackgroundImage('components/menu-hover.png'),
}"
></div>
<span class="flex items-center gap-2" :class="[!item.disabled ? 'menu-glow-text' : '']">
<span
class="flex items-center gap-2"
:class="[!item.disabled ? 'menu-glow-text' : '']"
>
<span>{{ item.label }}</span>
<img
class="w-10"
:src="
isViewed(item.routeName)
? '/src/assets/images/midterm/完成.png'
: '/src/assets/images/midterm/未完成.png'
isItemCompleted(item.routeName)
? getStaticImage('midterm/完成.png')
: getStaticImage('midterm/未完成.png')
"
alt=""
/>
@@ -183,8 +201,8 @@ const handleItemClick = (item: { routeName: string; disabled: boolean }) => {
<style scoped>
.filter-blue {
filter: brightness(0) saturate(100%) invert(79%) sepia(29%) saturate(1190%) hue-rotate(330deg)
brightness(102%) contrast(98%);
filter: brightness(0) saturate(100%) invert(79%) sepia(29%) saturate(1190%)
hue-rotate(330deg) brightness(102%) contrast(98%);
}
.menu-glow-text {
+77 -28
View File
@@ -1,5 +1,10 @@
<script lang="tsx" setup>
import { getBackgroundImage, getStaticImage, getPublicPath, IMAGE_PATHS } from '@/utils'
import {
getBackgroundImage,
getStaticImage,
getPublicPath,
IMAGE_PATHS,
} from '@/utils'
import { ImgButton } from '@/components/Button'
import { computed, onMounted, ref, watch, type Ref } from 'vue'
@@ -15,7 +20,7 @@ const film = useFilmStore()
const visible = computed({
get: () => film.filmMenuVisible,
set: value => film.setFilmMenuVisible(value)
set: (value) => film.setFilmMenuVisible(value),
})
// const status = ref(true)
@@ -26,25 +31,34 @@ const progress = computed(() => film.progressPercent)
const guideIndex = ref<number>(0)
const taskGuideKey = 'film-task-guide'
const practiceGuideKey = 'film-practice-guide'
const hasShownTaskGuide = computed(() => film.guideShown[taskGuideKey] === true)
const hasShownPracticeGuide = computed(
() => film.guideShown[practiceGuideKey] === true,
)
const showTaskGuide = computed(
() => currentStep.value === 1 && guideIndex.value < 2 && film.isGuideFilmTask
() => currentStep.value === 1 && guideIndex.value < 2 && film.isGuideFilmTask,
)
const showPracticeGuide = computed(
() => currentStep.value === 4 && !hasShownPracticeGuide.value,
)
// 思路提点完成回到首页的一次性引导
const showReturnGuide = ref(route.query.guide === 'return' && film.isGuideFilmReturn)
const showReturnGuide = ref(
route.query.guide === 'return' && film.isGuideFilmReturn,
)
const returnSteps: GuideStep[] = [
{
id: 'return-step1',
content: '现在,我们把镜头带到了徐汇滨江。',
audioSrc: getPublicPath('audio/py_4_010.MP3')
audioSrc: getPublicPath('audio/py_4_010.MP3'),
},
{
id: 'return-step2',
content: '这里既保留着工业时代的痕迹,也承载着当代生活的节奏。',
audioSrc: getPublicPath('audio/py_4_011.MP3')
}
audioSrc: getPublicPath('audio/py_4_011.MP3'),
},
]
const handleReturnGuideComplete = () => {
@@ -61,15 +75,16 @@ const stageGuideMap = {
steps: [
{
id: 'stage2-step1',
content: '拍摄工具本身不分好坏,关键在于是否适合当下的拍摄内容和使用习惯。',
audioSrc: getPublicPath('audio/py_4_012.MP3')
content:
'拍摄工具本身不分好坏,关键在于是否适合当下的拍摄内容和使用习惯。',
audioSrc: getPublicPath('audio/py_4_012.MP3'),
},
{
id: 'stage2-step2',
content: '我们一起看看在这个模块中会用到哪些常见、实用的拍摄工具。',
audioSrc: getPublicPath('audio/py_4_013.MP3')
}
]
audioSrc: getPublicPath('audio/py_4_013.MP3'),
},
],
},
3: {
flagKey: 'isGuideFilmStage3',
@@ -78,17 +93,17 @@ const stageGuideMap = {
{
id: 'stage3-step1',
content: '拍摄完成啦!接下来进入剪辑环节。',
audioSrc: getPublicPath('audio/py_4_017.MP3')
}
]
}
audioSrc: getPublicPath('audio/py_4_017.MP3'),
},
],
},
} as const
type StageKey = keyof typeof stageGuideMap
const stageGuideStep = ref<StageKey | null>(null)
const stageGuideSteps = computed(() =>
stageGuideStep.value ? stageGuideMap[stageGuideStep.value].steps : []
stageGuideStep.value ? stageGuideMap[stageGuideStep.value].steps : [],
) as Ref<GuideStep[]>
const triggerStageGuide = (step: number) => {
@@ -145,12 +160,12 @@ onMounted(() => {
// Cache the guide after it pops up once so it does not re-appear
watch(
showTaskGuide,
val => {
(val) => {
if (val && !hasShownTaskGuide.value) {
film.markGuideShown(taskGuideKey)
}
},
{ immediate: true }
{ immediate: true },
)
// 阶段引导触发:当 Overlay 菜单打开且当前步骤变化时检查是否需要弹阶段引导
@@ -161,7 +176,17 @@ watch(
triggerStageGuide(step)
}
},
{ immediate: true }
{ immediate: true },
)
watch(
showPracticeGuide,
(val) => {
if (val) {
film.markGuideShown(practiceGuideKey)
}
},
{ immediate: true },
)
</script>
@@ -198,24 +223,34 @@ watch(
<!-- 标题 -->
<div class="absolute top-2 left-1/2 -translate-x-1/2">
<img :src="getStaticImage('film/电影-创作与表达.png')" alt="" />
<img
:src="getStaticImage('film/电影-创作与表达.png')"
alt=""
/>
</div>
<!-- 任务取景按钮 -->
<div
class="absolute left-24 top-24"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.TASK_CAMERA) }"
:style="{
backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.TASK_CAMERA),
}"
>
<ImgButton
:image="currentStep === 4 ? IMAGE_PATHS.VLOG.TASK_BTN_OK : IMAGE_PATHS.VLOG.TASK_BTN"
:image="
currentStep > 0
? IMAGE_PATHS.VLOG.TASK_BTN_OK
: IMAGE_PATHS.VLOG.TASK_BTN
"
@click="handleWorkClick"
/>
</div>
<GuideSteps
:visible="currentStep === 4"
:visible="showPracticeGuide"
highlight-selector=".right-section"
position-offset-y="50%"
@complete="film.markGuideShown(practiceGuideKey)"
>
<GuideStepsItem>点击这里进入实战操作台</GuideStepsItem>
</GuideSteps>
@@ -228,7 +263,10 @@ watch(
:image="IMAGE_PATHS.VLOG.TASK_PERSON"
/>
<Overlay content-class="w-full h-full" :visible="visible && currentStep < 4">
<Overlay
content-class="w-full h-full"
:visible="visible && currentStep < 4"
>
<!-- 关闭按钮 -->
<ImgButton
class="absolute right-0 top-0 z-20"
@@ -241,9 +279,20 @@ watch(
class="absolute top-0 left-0 w-full h-full z-40 pointer-events-none"
v-if="showTaskGuide"
>
<div class="absolute pointer-events-auto w-full h-full" @click="handleGuideClick">
<img v-if="guideIndex === 0" :src="getStaticImage('film/on-site/1.png')" alt="" />
<img v-if="guideIndex === 1" :src="getStaticImage('film/on-site/2.png')" alt="" />
<div
class="absolute pointer-events-auto w-full h-full"
@click="handleGuideClick"
>
<img
v-if="guideIndex === 0"
:src="getStaticImage('film/on-site/1.png')"
alt=""
/>
<img
v-if="guideIndex === 1"
:src="getStaticImage('film/on-site/2.png')"
alt=""
/>
</div>
</div>
@@ -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'
@@ -15,8 +20,8 @@ const steps = ref([
{
id: 'step1',
content: '设备调试完毕,我们如约抵达徐汇滨江,一起开启拍摄之旅吧!',
audioSrc: getPublicPath('audio/py_4_014.MP3')
}
audioSrc: getPublicPath('audio/py_4_014.MP3'),
},
])
const showGuide = ref(false)
@@ -27,7 +32,13 @@ const hiddenbar = ref(false)
const currentIndex = ref(0) // 当前显示的图片索引
const selectedMenuIndex = ref(0) // 当前选中的菜单项索引
type EquipmentCategoryId = 'camera' | 'slider' | 'tripod' | 'stabilizer' | 'light' | 'filter'
type EquipmentCategoryId =
| 'camera'
| 'slider'
| 'tripod'
| 'stabilizer'
| 'light'
| 'filter'
interface EquipmentProduct {
img: string
}
@@ -51,8 +62,8 @@ const categories = ref<EquipmentCategory[]>([
products: [
{ img: getStaticImage('equipment/相机产品.png') },
{ img: getStaticImage('equipment/摄像机产品.png') },
{ img: getStaticImage('equipment/微单产品.png') }
]
{ img: getStaticImage('equipment/微单产品.png') },
],
},
{
id: 'slider',
@@ -62,8 +73,8 @@ const categories = ref<EquipmentCategory[]>([
contentImg: getStaticImage('equipment/滑轨内容.png'),
products: [
{ img: getStaticImage('equipment/便携滑轨产品.png') },
{ img: getStaticImage('equipment/脚架式长滑轨产品.png') }
]
{ img: getStaticImage('equipment/脚架式长滑轨产品.png') },
],
},
{
id: 'tripod',
@@ -71,7 +82,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: 'stabilizer',
@@ -81,8 +92,8 @@ const categories = ref<EquipmentCategory[]>([
contentImg: getStaticImage('equipment/稳定器内容.png'),
products: [
{ img: getStaticImage('equipment/手机稳定器.png') },
{ img: getStaticImage('equipment/相机稳定器.png') }
]
{ img: getStaticImage('equipment/相机稳定器.png') },
],
},
{
id: 'light',
@@ -92,8 +103,8 @@ const categories = ref<EquipmentCategory[]>([
contentImg: getStaticImage('equipment/灯内容.png'),
products: [
{ img: getStaticImage('equipment/led补光灯.png') },
{ img: getStaticImage('equipment/环形灯产品.png') }
]
{ img: getStaticImage('equipment/环形灯产品.png') },
],
},
{
id: 'filter',
@@ -101,14 +112,14 @@ 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,
}))
// 默认选中的菜单也算作已查看
@@ -118,9 +129,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(() => {
@@ -137,17 +150,22 @@ 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 currentContentImg = computed(
() => 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
}
@@ -180,9 +198,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"
@@ -190,7 +215,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)"
>
@@ -199,7 +224,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>
@@ -208,9 +236,15 @@ const prevPhone = () => {
<!-- 设备准备按钮 -->
<div class="equipment-bt">
<img src="@/assets/images/equipment/按钮选中-设备准备.png" alt="" />
<img
src="@/assets/images/equipment/按钮选中-设备准备.png"
alt=""
/>
</div>
<div :class="hiddenbar ? 'equipment-unfold1' : 'equipment-unfold2'" @click="hiddenbarclick()">
<div
:class="hiddenbar ? 'equipment-unfold1' : 'equipment-unfold2'"
@click="hiddenbarclick()"
>
<img
:src="
hiddenbar
@@ -228,36 +262,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,7 +1,12 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { getBackgroundImage, getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
import {
getBackgroundImage,
getPublicPath,
getStaticImage,
IMAGE_PATHS,
} from '@/utils'
import { ImgButton } from '@/components/Button'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { useFilmStore } from '@/stores/modules/film'
@@ -11,6 +16,54 @@ const filmStore = useFilmStore()
const guideKey = 'film-onsite-intro'
const showGuide = ref(!filmStore.guideShown[guideKey])
const completionIcon = (completed: boolean) =>
getStaticImage(`midterm/${completed ? '完成' : '未完成'}.png`)
const onsiteItems = computed(() => [
{
image: 'film/on-site/延时拍摄',
routeName: 'FilmOnsiteTimeLapse',
onClick: goTimeLapse,
class:
'absolute left-[100px] top-[210px] w-[446px] h-[273px] bg-cover bg-center bg-no-repeat',
},
{
image: 'film/on-site/滑轨拍摄',
routeName: 'FilmOnsiteSlider',
onClick: goSlider,
class:
'absolute left-[640px] top-[240px] w-[541px] h-[273px] bg-cover bg-center bg-no-repeat',
},
{
image: 'film/on-site/慢门拍摄-白天',
routeName: 'FilmOnsiteSlowShutterDay',
onClick: goSlowShutterDay,
class:
'absolute right-[0px] top-[220px] w-[541px] h-[273px] bg-cover bg-center bg-no-repeat',
},
{
image: 'film/on-site/慢门拍摄-夜间.png',
routeName: 'FilmOnsiteSlowShutterNight',
onClick: goSlowShutterNight,
class:
'absolute right-[0px] bottom-[160px] w-[541px] h-[273px] bg-cover bg-center bg-no-repeat',
},
{
image: 'film/on-site/低角度拍摄',
routeName: 'FilmOnsiteLowAngle',
onClick: goLowAngle,
class:
'absolute left-[100px] bottom-[80px] w-[446px] h-[273px] bg-cover bg-center bg-no-repeat',
},
{
image: 'film/on-site/创作随机性',
routeName: 'FilmOnsiteRandom',
onClick: goRandom,
class:
'absolute left-[700px] bottom-[40px] w-[446px] h-[273px] bg-cover bg-center bg-no-repeat',
},
])
if (showGuide.value) {
filmStore.markGuideShown(guideKey)
}
@@ -53,86 +106,27 @@ const goRandom = () => {
></div>
<!-- 标题 -->
<img class="absolute left-0 top-0" :src="getStaticImage('film/on-site/标题.png')" alt="" />
<img
class="absolute left-0 top-0"
:src="getStaticImage('film/on-site/标题.png')"
alt=""
/>
<div
class="absolute left-[100px] top-[210px] w-[446px] h-[273px] bg-cover bg-center bg-no-repeat"
v-for="item in onsiteItems"
:key="item.routeName"
:class="item.class"
>
<ImgButton :image="'film/on-site/延时拍摄'" @click="goTimeLapse" />
<ImgButton
:image="item.image"
@click="item.onClick"
/>
<img
class="pointer-events-none absolute left-6 top-2 z-10 h-12 w-12"
:src="completionIcon(filmStore.viewed[item.routeName] === true)"
alt=""
/>
</div>
<div
class="absolute left-[640px] top-[240px] w-[541px] h-[273px] bg-cover bg-center bg-no-repeat"
>
<ImgButton :image="'film/on-site/滑轨拍摄'" @click="goSlider" />
</div>
<div
class="absolute right-[0px] top-[220px] w-[541px] h-[273px] bg-cover bg-center bg-no-repeat"
>
<ImgButton :image="'film/on-site/慢门拍摄-白天'" @click="goSlowShutterDay" />
</div>
<div
class="absolute right-[0px] bottom-[160px] w-[541px] h-[273px] bg-cover bg-center bg-no-repeat"
>
<ImgButton :image="'film/on-site/慢门拍摄-夜间.png'" @click="goSlowShutterNight" />
</div>
<div
class="absolute left-[100px] bottom-[80px] w-[446px] h-[273px] bg-cover bg-center bg-no-repeat"
>
<ImgButton :image="'film/on-site/低角度拍摄'" @click="goLowAngle" />
</div>
<div
class="absolute left-[700px] bottom-[40px] w-[446px] h-[273px] bg-cover bg-center bg-no-repeat"
>
<ImgButton :image="'film/on-site/创作随机性'" @click="goRandom" />
</div>
<!-- 内容卡片 + 按钮 -->
<!-- <div class="absolute inset-0 flex items-center justify-center px-16 pointer-events-none">
<div
class="max-w-screen-2xl w-full bg-black/35 rounded-3xl border border-white/15 p-16 space-y-10 text-white pointer-events-auto"
>
<div class="flex items-baseline justify-between gap-6">
<h1 class="text-5xl font-extrabold tracking-wide">现场拍摄</h1>
<span class="text-3xl opacity-80">从不同维度练习你的镜头语言</span>
</div>
<p class="text-2xl leading-8 opacity-90">
选择下面任意一个练习模块,进入对应的现场拍摄场景,按提示完成小任务,逐步建立自己的拍摄手感和创作节奏。
</p>
<div class="grid grid-cols-3 gap-10">
<button type="button" class="onsite-btn" @click="goTimeLapse">
<span class="onsite-btn-title">延时拍摄</span>
<span class="onsite-btn-desc">记录时间流动,让平凡场景变得有节奏、有变化。</span>
</button>
<button type="button" class="onsite-btn" @click="goSlider">
<span class="onsite-btn-title">滑轨拍摄</span>
<span class="onsite-btn-desc">通过平稳移动镜头,营造空间感和叙事推进感。</span>
</button>
<button type="button" class="onsite-btn" @click="goSlowShutterDay">
<span class="onsite-btn-title">慢门拍摄 · 白天</span>
<span class="onsite-btn-desc">在白天环境下练习慢门,营造水流、车流等运动轨迹。</span>
</button>
<button type="button" class="onsite-btn" @click="goSlowShutterNight">
<span class="onsite-btn-title">慢门拍摄 · 夜晚</span>
<span class="onsite-btn-desc">利用城市灯光,拍出具有节奏感和氛围感的夜景画面。</span>
</button>
<button type="button" class="onsite-btn" @click="goLowAngle">
<span class="onsite-btn-title">低角度拍摄</span>
<span class="onsite-btn-desc">从低视角观察世界,突出主体气势,改变日常视角。</span>
</button>
<button type="button" class="onsite-btn" @click="goRandom">
<span class="onsite-btn-title">创作随机性</span>
<span class="onsite-btn-desc">跟随直觉自由取景,捕捉那些意料之外的画面惊喜。</span>
</button>
</div>
</div>
</div> -->
<!-- 关闭 -->
<ImgButton
@@ -142,7 +136,10 @@ const goRandom = () => {
/>
</div>
<GuideSteps :visible="showGuide" position-offset-y="85%">
<GuideSteps
:visible="showGuide"
position-offset-y="85%"
>
<GuideStepsItem :audio-src="getPublicPath('audio/py_4_016.MP3')">
接下来进入核心拍摄环节,我们依次来学习!
</GuideStepsItem>
@@ -11,8 +11,10 @@ import {
getStaticImage,
IMAGE_PATHS,
} from '@/utils'
import { useFilmStore } from '@/stores/modules/film'
const router = useRouter()
const filmStore = useFilmStore()
const handleBack = () => {
router.back()
@@ -53,6 +55,7 @@ const showSummaryStep = ref(false)
// 答题完成,进入交互三(总结)
const handleQuestionsComplete = () => {
showSummaryStep.value = true
filmStore.markViewed('FilmOnsiteLowAngle')
}
</script>
@@ -4,8 +4,10 @@ import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { Video } from '@/components'
import { getPublicPath, IMAGE_PATHS } from '@/utils'
import { useFilmStore } from '@/stores/modules/film'
const router = useRouter()
const filmStore = useFilmStore()
const handleBack = () => {
router.back()
@@ -16,10 +18,16 @@ const randomVideoUrl = getPublicPath('video/film/on-site/dygmk_czsjx_batch.mp4')
// 视频播放状态
const videoPlaying = ref(false)
const handleVideoEnded = () => {
filmStore.markViewed('FilmOnsiteRandom')
}
</script>
<template>
<div class="relative w-full h-full flex items-center justify-center overflow-hidden">
<div
class="relative w-full h-full flex items-center justify-center overflow-hidden"
>
<!-- 视频播放区域 -->
<div class="w-full h-full">
<Video
@@ -31,6 +39,7 @@ const videoPlaying = ref(false)
:controls="true"
:hideNativeControls="true"
:autoplay="true"
@ended="handleVideoEnded"
/>
</div>
@@ -7,8 +7,10 @@ import QuestionQuiz, {
type Question,
} from '@/components/QuestionQuiz/QuestionQuiz.vue'
import { getPublicPath, IMAGE_PATHS } from '@/utils'
import { useFilmStore } from '@/stores/modules/film'
const router = useRouter()
const filmStore = useFilmStore()
const handleBack = () => {
router.back()
@@ -54,6 +56,7 @@ const questions = ref<Question[]>([
// 答题完成
const handleQuestionsComplete = () => {
showQuiz.value = false // 答题成功后关闭答题窗口
filmStore.markViewed('FilmOnsiteSlider')
}
</script>
@@ -11,8 +11,10 @@ import {
getStaticImage,
IMAGE_PATHS,
} from '@/utils'
import { useFilmStore } from '@/stores/modules/film'
const router = useRouter()
const filmStore = useFilmStore()
const handleBack = () => {
router.back()
@@ -55,6 +57,7 @@ const showSummaryStep = ref(false)
// 答题完成,进入交互三(总结)
const handleQuestionsComplete = () => {
showSummaryStep.value = true
filmStore.markViewed('FilmOnsiteSlowShutterDay')
}
</script>
@@ -4,18 +4,26 @@ import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { Video } from '@/components'
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
import { useFilmStore } from '@/stores/modules/film'
const router = useRouter()
const filmStore = useFilmStore()
const handleBack = () => {
router.back()
}
// 慢门拍摄夜晚视频 URL(暂时用其他视频代替)
const slowShutterNightVideoUrl = getPublicPath('video/film/on-site/dygmk_mmps_batch.mp4')
const slowShutterNightVideoUrl = getPublicPath(
'video/film/on-site/dygmk_mmps_batch.mp4',
)
// 视频播放状态
const videoPlaying = ref(false)
const handleVideoEnded = () => {
filmStore.markViewed('FilmOnsiteSlowShutterNight')
}
</script>
<template>
@@ -37,6 +45,7 @@ const videoPlaying = ref(false)
:controls="true"
:hideNativeControls="true"
:autoplay="true"
@ended="handleVideoEnded"
/>
</div>
@@ -7,8 +7,10 @@ import QuestionQuiz, {
type Question,
} from '@/components/QuestionQuiz/QuestionQuiz.vue'
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
import { useFilmStore } from '@/stores/modules/film'
const router = useRouter()
const filmStore = useFilmStore()
// 延时拍摄视频 URL(占位,后续可替换)
const timeLapseVideoUrl = getPublicPath(
@@ -57,6 +59,7 @@ const videoPlaying = ref(false) // 视频播放状态
// 答题完成,进入交互三(视频)
const handleQuestionsComplete = () => {
showObserveStep.value = true
filmStore.markViewed('FilmOnsiteTimeLapse')
}
</script>
@@ -137,7 +140,7 @@ const handleQuestionsComplete = () => {
<!-- 返回按钮 -->
<ImgButton
class="absolute right-0 top-0 z-20"
class="absolute right-0 top-0 z-50"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleBack"
/>
@@ -1,29 +1,15 @@
<script lang="ts" setup>
import { ref, computed } from 'vue'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { GuideSteps } from '@/components/Guide'
import { Overlay } from '@/components/Overlay'
import { GuideSteps, type GuideStep } from '@/components/Guide'
import { ImgButton } from '@/components/Button'
import {
getBackgroundImage,
getPublicPath,
getStaticImage,
IMAGE_PATHS,
} from '@/utils'
import { getBackgroundImage, getPublicPath } from '@/utils'
import shootingtitle from '@/assets/images/shooting/标题-实地取景.png'
import videoSrc from '/video/new/composition/xsdh_jzgtf_batch.mp4'
import KnowledgeCard from '@/components/KnowledgeCard/FilmKnowledgeCard.vue'
import { Video } from '@/components'
import CompositionGuide from '@/components/CompositionGuide.vue'
import { useFilmStore } from '@/stores/modules/film'
type Btn = {
key: string
name: string
btnImage: string
images: string[]
desc: string
}
const router = useRouter()
const filmStore = useFilmStore()
const introGuideKey = 'film-shooting-intro'
@@ -35,7 +21,7 @@ if (showIntroGuide.value) {
filmStore.markGuideShown(introGuideKey)
}
const steps = ref([
const steps = ref<GuideStep[]>([
{
id: '1',
audioSrc: getPublicPath('audio/py_4_015.MP3'),
@@ -44,7 +30,7 @@ const steps = ref([
},
])
const guideSteps = ref([
const guideSteps = ref<GuideStep[]>([
{
id: '1',
content: '点击框架构图,查看构图方法',
@@ -63,17 +49,7 @@ const guideSteps = ref([
])
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: '框架构图',
@@ -100,10 +76,6 @@ const btnList = ref<Btn[]>([
},
])
const handleModalClose = () => {
showModal.value = false
}
const openCompositionGuide = () => {
showModal.value = true
if (showCompositionGuide.value) {
@@ -111,26 +83,13 @@ const openCompositionGuide = () => {
}
}
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: 'FilmFilmingLocationA' })
}
const handleClickB = () => {
// 跳转设备准备
router.push({ name: 'FilmFilmingLocationB' })
}
const back = () => {
// 跳转设备准备
router.back()
}
</script>
@@ -142,13 +101,13 @@ const back = () => {
:style="{ backgroundImage: getBackgroundImage('film/shooting/底图') }"
></div>
<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"
@@ -168,7 +127,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="openCompositionGuide"
@@ -184,127 +143,16 @@ const back = () => {
position-offset-y="20%"
/>
<!-- 保存图片弹窗 -->
<Overlay
<!-- 构图指南弹窗 -->
<CompositionGuide
:visible="showModal"
:clickable="false"
:customStyle="{
backgroundImage: getBackgroundImage('film/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/标题-构图指南"
/>
<!-- 新手导航视频 -->
<div class="absolute left-28 top-[calc(50%-40px)] -translate-y-1/2">
<ImgButton
image="shooting/新手导航-视频回顾"
@click="showModal3 = true"
/>
<div
class="absolute left-[40%] top-[42%] -translate-x-1/2 -translate-y-1/2 z-10 animate-bounce pointer-events-none"
>
<img
class="w-32"
:src="getStaticImage('work/小手')"
alt=""
/>
<span class="text-white text-2xl font-bold ml-5">点击进入</span>
</div>
</div>
<div class="absolute top-[30%] right-10">
<ImgButton
v-for="item in btnList"
:key="item.key"
:class="`btn-item-${item.key}`"
:image="item.btnImage"
@click="handleClick(item)"
/>
</div>
<GuideSteps
:visible="showCompositionGuide"
:steps="guideSteps"
:keyboard-navigation="true"
:highlight-padding="12"
:highlight-radius="16"
position-offset-y="85%"
/>
<!-- 知识卡片 -->
<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="film/shooting/构图指南底图"
:btn-list="btnList"
:knowledge-card-component="KnowledgeCard"
:video-src="videoSrc"
:guide-steps="guideSteps"
:show-internal-guide="showCompositionGuide"
@close="showModal = false"
/>
</div>
</template>
<style lang="less" scoped>
.shooting-title {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
.shooting-return {
position: absolute;
right: 0;
top: 0;
}
.shooting-left {
position: absolute;
left: 16%;
top: 50%;
}
.shooting-right {
position: absolute;
right: 16%;
top: 50%;
}
.shooting-composition {
position: absolute;
left: 2rem;
bottom: 1rem;
}
</style>
+12 -1
View File
@@ -81,6 +81,17 @@ const worksList = ref<WorkItem[]>([
},
])
const getCachedWorkScore = (work: WorkItem): number => {
const scores = filmStore.practiceWorkRatings[work.path]
const values = scores ? Object.values(scores) : []
if (values.length === 0) return work.stars
return Math.round(
values.reduce((total, score) => total + score, 0) / values.length,
)
}
/** 左滑动作品列表 */
const scrollWorksLeft = (): void => {
if (worksScrollIndex.value > 0) {
@@ -306,7 +317,7 @@ onUnmounted(() => {
</h3>
<!-- 星星评分 -->
<StarRating
:score="work.stars"
:score="getCachedWorkScore(work)"
size="lg"
readonly
/>
+50 -7
View File
@@ -1,19 +1,37 @@
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { computed, onBeforeUnmount, ref } from 'vue'
import { useRouter } from 'vue-router'
import { getBackgroundImage, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button'
import PracticeRatingModal, {
type PracticeRatingItem,
} from '@/components/PracticeRatingModal'
import { useFilmStore } from '@/stores/modules/film'
const router = useRouter()
const store = useFilmStore()
type PracticeRatingScores = Record<string, number>
const applyScores = (
items: PracticeRatingItem[],
scores: PracticeRatingScores,
): PracticeRatingItem[] =>
items.map((item) => ({
...item,
score: scores[item.title] ?? item.score,
}))
const toScores = (items: PracticeRatingItem[]): PracticeRatingScores =>
Object.fromEntries(items.map((item) => [item.title, item.score]))
/** 笔记内容 */
const noteContent = ref<string>('')
const noteContent = ref<string>(store.practiceNoteContent)
/** 保存弹窗 */
const showModal = ref<boolean>(false)
const showSuccess = ref<boolean>(false)
let successTimer: number | undefined
/** 返回上一页 */
const handleBack = (): void => {
@@ -23,7 +41,7 @@ const handleBack = (): void => {
const disabled = computed<boolean>(() => noteContent.value.trim() === '')
/** 评估项目列表 */
const evaluationItems = ref<PracticeRatingItem[]>([
const defaultEvaluationItems: PracticeRatingItem[] = [
{
title: '画面氛围',
description: '画面具有明确情绪或空间气质,整体风格统一',
@@ -49,11 +67,28 @@ const evaluationItems = ref<PracticeRatingItem[]>([
description: '作品具有“电影感”,完成度高',
score: 5,
},
])
]
const evaluationItems = ref<PracticeRatingItem[]>(
applyScores(defaultEvaluationItems, store.practiceSelfEvaluationScores),
)
const handleSave = (): void => {
console.log('保存笔记')
handleBack()
store.setPracticeNoteContent(noteContent.value)
showSuccess.value = true
window.clearTimeout(successTimer)
successTimer = window.setTimeout(() => {
showSuccess.value = false
}, 1500)
}
const handleSaveEvaluation = (items: PracticeRatingItem[]): void => {
store.setPracticeSelfEvaluationScores(toScores(items))
}
onBeforeUnmount(() => {
window.clearTimeout(successTimer)
})
</script>
<template>
@@ -76,6 +111,13 @@ const handleSave = (): void => {
placeholder="点击输入创作笔记"
></textarea>
<div
v-if="showSuccess"
class="absolute left-1/2 top-1/2 z-[100] -translate-x-1/2 -translate-y-1/2 rounded-2xl bg-black/70 px-16 py-8 text-5xl font-bold text-white"
>
提交成功
</div>
<div class="absolute bottom-24 left-1/2 -translate-x-1/2 flex gap-60">
<ImgButton
image="vlog/practice/保存"
@@ -90,10 +132,11 @@ const handleSave = (): void => {
<PracticeRatingModal
:visible="showModal"
:items="evaluationItems"
v-model:items="evaluationItems"
background-image="film/practice/电影感短视频创作-作品评分表"
top-tip="评分时要关注5个维度哦~分别是画面氛围、抽摄方法、剪辑逻辑、创作意识、综合观感,每个维度满分5分,能打1-5分,记得对照作品表现来打分呀~"
bottom-tip="己评价的实战作品,从封面入口就能直接看到具体评分!打了多少分一目了然,不用再点进详情页翻找啦~"
@save="handleSaveEvaluation"
@close="showModal = false"
/>
</div>
+33 -4
View File
@@ -6,11 +6,12 @@ import PracticeRatingModal, {
type PracticeRatingItem,
} from '@/components/PracticeRatingModal'
import { IMAGE_PATHS, getPublicPath } from '@/utils'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { useFilmStore } from '@/stores/modules/film'
const router = useRouter()
const filmStore = useFilmStore()
const store = filmStore
const guideKey = 'film-practice-video-rating'
const showGuide = ref(!filmStore.guideShown[guideKey])
@@ -25,8 +26,24 @@ const props = defineProps({
},
})
type PracticeRatingScores = Record<string, number>
const workKey = computed<string>(() => decodeURIComponent(props.path))
const applyScores = (
items: PracticeRatingItem[],
scores: PracticeRatingScores,
): PracticeRatingItem[] =>
items.map((item) => ({
...item,
score: scores[item.title] ?? item.score,
}))
const toScores = (items: PracticeRatingItem[]): PracticeRatingScores =>
Object.fromEntries(items.map((item) => [item.title, item.score]))
/** 评估项目列表 */
const evaluationItems = ref<PracticeRatingItem[]>([
const defaultEvaluationItems: PracticeRatingItem[] = [
{
title: '画面氛围',
description: '画面具有明确情绪或空间气质,整体风格统一',
@@ -52,7 +69,18 @@ const evaluationItems = ref<PracticeRatingItem[]>([
description: '作品具有“电影感”,完成度高',
score: 5,
},
])
]
const evaluationItems = ref<PracticeRatingItem[]>(
applyScores(
defaultEvaluationItems,
store.getPracticeWorkRating(workKey.value),
),
)
const handleSaveEvaluation = (items: PracticeRatingItem[]): void => {
store.setPracticeWorkRating(workKey.value, toScores(items))
}
/** 保存弹窗 */
const showModal = ref<boolean>(false)
@@ -85,10 +113,11 @@ const handleBack = (): void => {
<PracticeRatingModal
:visible="showModal"
:items="evaluationItems"
v-model:items="evaluationItems"
background-image="film/practice/电影感短视频创作-作品评分表"
top-tip="评分时要关注5个维度哦~分别是画面氛围、抽摄方法、剪辑逻辑、创作意识、综合观感,每个维度满分5分,能打1-5分,记得对照作品表现来打分呀~"
bottom-tip="己评价的实战作品,从封面入口就能直接看到具体评分!打了多少分一目了然,不用再点进详情页翻找啦~"
@save="handleSaveEvaluation"
@close="showModal = false"
/>
<GuideSteps
+78 -5
View File
@@ -1,9 +1,10 @@
<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, GuideStepsItem } from '@/components/Guide'
import StepNavigation from '@/components/Guide/StepNavigation.vue'
import UnfinishedLearningPrompt from '@/components/UnfinishedLearningPrompt'
import { Video } from '@/components'
import QuestionQuiz, {
type Question,
@@ -64,13 +65,13 @@ const navigationSteps = ref([
content: '分镜头',
},
{
id: 'step5',
id: 'step6',
label: '后期制作',
videoUrl: getPublicPath('video/film/work/dygmk_lj_batch.mp4'),
content: '滤镜',
},
{
id: 'step5',
id: 'step7',
label: '拍摄执行',
videoUrl: getPublicPath('video/film/work/dygmk_tqs_batch.mp4'),
content: '同期声',
@@ -79,10 +80,47 @@ const navigationSteps = ref([
// 步骤导航相关状态
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 = [
'film-profession-zhefu',
'film-profession-guangxian',
'film-profession-xushi',
'film-profession-texiejingtou',
'film-profession-fenjingtou',
'film-profession-lvjing',
'film-profession-tongqisheng',
] as const
const completedNavigationSteps = computed(() =>
navigationSteps.value.map(
(_, index) =>
film.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
film.markProfessionVideoWatched(videoKey)
const isAllWatched = professionVideoKeys.every(
(key) => key === videoKey || film.professionVideosWatched[key] === true,
)
if (isAllWatched) film.markWorkVideoWatched('profession')
}
// 题目数据:虚实(index 2)和分镜头(index 4)
const questions = ref<Question[]>([])
@@ -94,7 +132,7 @@ const getQuestionsForVideo = (index: number): Question[] => {
{
id: 1,
title: '',
description: '(单选题)下面两个画面,哪一个更具电影叙事感?',
description: '【单选题】下面两个画面,哪一个更具电影叙事感?',
options: [
{
id: 'a',
@@ -117,7 +155,7 @@ const getQuestionsForVideo = (index: number): Question[] => {
{
id: 2,
title: '',
description: '(单选题)下面哪一组镜头顺序,更符合电影叙事逻辑?',
description: '【单选题】下面哪一组镜头顺序,更符合电影叙事逻辑?',
options: [
{
id: 'a',
@@ -169,6 +207,7 @@ const handleVideoChange = () => {
const handleClose = () => {
if (key.value === 'idea') {
film.markWorkVideoWatched('idea')
film.setFilmUnlockProgress(1)
router.push({
name: 'Film',
@@ -190,7 +229,14 @@ const handlePlay = () => {
}
const onEnded = () => {
if (key.value === 'case') {
film.markWorkVideoWatched('case')
return
}
if (key.value === 'profession') {
markCurrentProfessionVideoWatched()
// 检查是否是"虚实"(index 2)或"分镜头"(index 4)
if (currentVideoIndex.value === 2 || currentVideoIndex.value === 4) {
// 暂停播放并显示答题窗口
@@ -206,6 +252,7 @@ const onEnded = () => {
const handleComplete = () => {
if (key.value === 'profession') {
film.markWorkVideoWatched('profession')
if (!film.isFilmUnlock) film.setIsFilmUnlock(true)
router.push({
name: 'FilmWork',
@@ -243,6 +290,25 @@ const handleProfessionGuideComplete = () => {
})
}
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(() => {})
</script>
@@ -330,6 +396,7 @@ onMounted(() => {})
:background-image="getStaticImage('work/vlog背景全屏待定')"
:steps="navigationSteps"
:current-step-index="currentNavigationStep"
:completed-steps="completedNavigationSteps"
@step-click="handleStepClick"
@close="handleStepNavigationClose"
/>
@@ -379,6 +446,12 @@ onMounted(() => {})
</GuideSteps>
<!-- 引导与高亮 -->
<UnfinishedLearningPrompt
:visible="showUnfinishedPrompt"
@continue="handleContinueLearning"
@leave="handleConfirmLeave"
/>
<GuideSteps
highlight-selector=""
v-if="visibleGuide2 && key === 'idea'"
+5 -2
View File
@@ -21,7 +21,10 @@ const film = useFilmStore()
type WorkTab = 'case' | 'profession' | 'idea'
const selected = ref<WorkTab>('case')
const selected = computed({
get: () => film.workSelectedTab as WorkTab,
set: (value: WorkTab) => film.setWorkSelectedTab(value),
})
const stepIndex = ref(0)
const workSideSwitcherRef = ref<InstanceType<typeof WorkSideSwitcher>>()
@@ -218,7 +221,7 @@ function openImages(urls: string[], startIndex = 0) {
但所谓高级感,到底来自哪里?
</GuideStepsItem>
<GuideStepsItem :audio-src="getPublicPath('audio/py_4_003.MP3')">
带着这个问题,我们一起来看看“钟表博物馆”的案例,看看普通画面是如何被赋予质感与故事感的。
带着这个问题,我们一起来看看“钟表文化科普馆”的案例,看看普通画面是如何被赋予质感与故事感的。
</GuideStepsItem>
</GuideSteps>
+68 -7
View File
@@ -11,7 +11,7 @@ import {
import { ImgButton } from '@/components/Button'
import { ModuleSwitcher } from './components/ModuleSwitcher'
import StudyProgressPopup from './components/StudyProgressPopup.vue'
import { computed, ref, onMounted } from 'vue'
import { computed, ref, nextTick, onMounted } from 'vue'
import { useRouter, type RouteRecordName } from 'vue-router'
import {
useUserStore,
@@ -22,6 +22,11 @@ import {
} from '@/stores'
import { GuideSteps } from '@/components/Guide'
import type { GuideStep } from '@/components/Guide/types'
import {
getBasicElementsProgress,
getOverallStudyProgress,
getProductionModuleProgress,
} from './components/studyProgressUtils'
type GuideChildNode = {
id: number
@@ -87,7 +92,7 @@ const homeGuideSteps: GuideStep[] = [
const unlockedHomeGuideSteps: GuideStep[] = [
{
id: 'home-guide-welcome',
content: '欢迎来到新手村!在这里,我会带着你解锁短视频创作的核心基本功!',
content: '欢迎来到新手模块!在这里,我会带着你解锁短视频创作的核心基本功!',
audioSrc: getPublicPath('audio/py_1_001.MP3'),
},
{
@@ -108,6 +113,7 @@ const unlockedHomeGuideSteps: GuideStep[] = [
const elementGuideSteps: GuideStep[] = [
{
id: 'home-guide-start-element',
highlightSelector: '.child-node-buttons',
content: '选择任意要素开始学习~',
positionOffsetY: '20%',
audioSrc: getPublicPath('audio/py_1_004.MP3'),
@@ -210,6 +216,32 @@ const selectedImage = computed(() => {
return getBackgroundImage(imageMap[selectedModule.value])
})
const filmViewed = computed(() => ({
...film.viewed,
FilmOnsiteShooting: film.allOnsiteScenesViewed,
FilmEditing: film.allEditingVideosWatched,
}))
const totalStudyPercent = computed(() =>
getOverallStudyProgress([
getBasicElementsProgress(
basicElements.basicElementsUnlockProgress,
basicElements.isBasicElementsUnlock,
basicElements.learningVideosWatched,
),
getProductionModuleProgress('vlog', vlog.viewed, vlog.workVideosWatched),
getProductionModuleProgress(
'interview',
interview.viewed,
interview.workVideosWatched,
),
getProductionModuleProgress(
'film',
filmViewed.value,
film.workVideosWatched,
),
]),
)
const isSkillDisabled = computed(() => !hasFinishedActionGuide.value)
const handleOpenStudyProgress = () => {
@@ -291,6 +323,7 @@ const handleNodeClick = (node: GuideNode, event?: MouseEvent) => {
event?.stopPropagation()
if (!node.hasChildren) {
showChildrenNodeId.value = null
if (node.routeName) {
router.push({ name: node.routeName })
}
@@ -304,7 +337,11 @@ const handleNodeClick = (node: GuideNode, event?: MouseEvent) => {
showChildrenNodeId.value = node.id
if (activeActionGuide.value !== 'unlocked' && !hasFinishedActionGuide.value) {
activeActionGuide.value = 'element'
nextTick(() => {
requestAnimationFrame(() => {
activeActionGuide.value = 'element'
})
})
}
}
@@ -365,6 +402,10 @@ onMounted(() => {
<StudyProgressPopup v-model="studyProgressVisible" />
<div class="absolute top-[7.5rem] left-48 pointer-events-none">
<span class="text-5xl italic text-white">{{ totalStudyPercent }}%</span>
</div>
<div
class="absolute top-4 left-4 flex items-center text-white text-xl bg-black/10 px-4 py-2 rounded-xl"
>
@@ -451,7 +492,10 @@ onMounted(() => {
@click="handleClickOutside"
>
<!-- 标题区域 -->
<div class="text-center mt-10 mb-8">
<div
class="text-center mt-10 mb-8 transition-opacity duration-300"
:class="showChildrenNodeId !== null ? 'opacity-35' : 'opacity-100'"
>
<h2
class="text-5xl font-bold mb-3 text-[#7bfffd] tracking-wider drop-shadow-lg"
>
@@ -485,6 +529,10 @@ onMounted(() => {
stroke="#7bfffd"
stroke-width="5"
stroke-linecap="round"
class="transition-opacity duration-300"
:class="
showChildrenNodeId !== null ? 'opacity-35' : 'opacity-100'
"
/>
<!-- 节点2 -> 节点3 -->
<line
@@ -495,6 +543,10 @@ onMounted(() => {
stroke="#7bfffd"
stroke-width="5"
stroke-linecap="round"
class="transition-opacity duration-300"
:class="
showChildrenNodeId !== null ? 'opacity-35' : 'opacity-100'
"
/>
<!-- 节点3 -> 右侧装饰线 -->
<line
@@ -505,6 +557,10 @@ onMounted(() => {
stroke="#7bfffd"
stroke-width="5"
stroke-linecap="round"
class="transition-opacity duration-300"
:class="
showChildrenNodeId !== null ? 'opacity-35' : 'opacity-100'
"
/>
</svg>
@@ -512,7 +568,12 @@ onMounted(() => {
<div
v-for="node in nodes"
:key="node.id"
class="absolute transform -translate-x-1/2 -translate-y-1/2"
class="absolute transform -translate-x-1/2 -translate-y-1/2 transition-opacity duration-300"
:class="
showChildrenNodeId !== null && showChildrenNodeId !== node.id
? 'opacity-35'
: 'opacity-100'
"
:style="{ left: node.x + '%', top: node.y + '%' }"
@click="handleNodeClick(node, $event)"
>
@@ -547,10 +608,10 @@ onMounted(() => {
<Transition name="child-node-pop">
<div
v-if="node.hasChildren && showChildrenNodeId === node.id"
class="child-node-buttons btn absolute -top-48 left-1/2 z-20 flex -translate-x-1/2 flex-col items-center"
class="child-node-buttons absolute -top-48 left-1/2 z-20 flex -translate-x-1/2 flex-col items-center"
>
<div
class="relative flex rounded-[30px] bg-[rgba(8,40,58,0.82)] px-3 py-2 shadow-[0_0_0_1px_rgba(123,255,253,0.28),0_0_20px_rgba(123,255,253,0.28)] after:absolute after:left-1/2 after:top-[calc(100%-1px)] after:h-0 after:w-0 after:-translate-x-1/2 after:border-x-[12px] after:border-t-[14px] after:border-x-transparent after:border-t-[rgba(8,40,58,0.82)]"
class="relative flex rounded-[30px] bg-[rgba(78,195,217,1)] backdrop-filter px-3 py-2 shadow-[0_0_0_2px_rgba(250,250,250,1),0_0_20px_rgba(121,252,249,1)] after:absolute after:left-1/2 after:top-[calc(100%-9px)] after:h-5 after:w-5 after:-translate-x-1/2 after:rotate-45 after:border-r-2 after:border-b-2 after:border-white after:bg-[rgba(78,195,217,1)]"
>
<div
v-for="childNode in node.children"
@@ -1,13 +1,29 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { getBackgroundImage } from '@/utils'
import { useBasicElementsStore, useFilmStore, useInterviewStore, useVlogStore } from '@/stores'
import { AnchoredProgressBar, ImgButton, type ProgressAnchor } from '@/components'
import { studyModuleAnchors, type StudyProgressModuleKey } from './studyProgressConfig'
import { getProductionModuleProgress } from './studyProgressUtils'
import {
useBasicElementsStore,
useFilmStore,
useInterviewStore,
useVlogStore,
} from '@/stores'
import {
AnchoredProgressBar,
ImgButton,
type ProgressAnchor,
} from '@/components'
import {
studyModuleAnchors,
type StudyProgressModuleKey,
} from './studyProgressConfig'
import {
getBasicElementsProgress,
getOverallStudyProgress,
getProductionModuleProgress,
} from './studyProgressUtils'
defineOptions({
name: 'StudyProgressPopup'
name: 'StudyProgressPopup',
})
type StudyProgressItem = {
@@ -27,42 +43,68 @@ const rowPositions = {
basic: { height: 168 },
vlog: { height: 148 },
interview: { height: 146 },
film: { height: 146 }
film: { height: 146 },
} as const
const labelTextShadow =
'0 0 5px rgba(255,255,255,0.95), 0 0 10px rgba(54,111,255,0.75), 0 3px 0 rgba(0, 48, 151, 0.75)'
const totalTextShadow = '4px 5px 0 rgba(40, 130, 253, 0.75)'
const childTextShadow = '0 0 6px rgba(54,250,255,0.9), 0 2px 0 rgba(0, 68, 151, 0.7)'
const childTextShadow =
'0 0 6px rgba(54,250,255,0.9), 0 2px 0 rgba(0, 68, 151, 0.7)'
const basicProgress = computed(() =>
basicElements.isBasicElementsUnlock ? 100 : basicElements.basicElementsUnlockProgress
getBasicElementsProgress(
basicElements.basicElementsUnlockProgress,
basicElements.isBasicElementsUnlock,
basicElements.learningVideosWatched,
),
)
const vlogProgress = computed(() =>
getProductionModuleProgress('vlog', vlog.viewed, vlog.workVideosWatched),
)
const interviewProgress = computed(() =>
getProductionModuleProgress(
'interview',
interview.viewed,
interview.workVideosWatched,
),
)
const filmViewed = computed(() => ({
...film.viewed,
FilmOnsiteShooting: film.allOnsiteScenesViewed,
FilmEditing: film.allEditingVideosWatched,
}))
const filmProgress = computed(() =>
getProductionModuleProgress('film', filmViewed.value, film.workVideosWatched),
)
const totalProgress = computed(() =>
getOverallStudyProgress([
basicProgress.value,
vlogProgress.value,
interviewProgress.value,
filmProgress.value,
]),
)
const totalProgress = computed(() => {
const total =
basicProgress.value + vlog.progressPercent + interview.progressPercent + film.progressPercent
return Math.round(total / 4)
})
const progressItems = computed<StudyProgressItem[]>(() => [
{
key: 'basic',
progress: basicProgress.value,
anchors: studyModuleAnchors.basic
anchors: studyModuleAnchors.basic,
},
{
key: 'vlog',
progress: getProductionModuleProgress('vlog', vlog.viewed),
anchors: studyModuleAnchors.vlog
progress: vlogProgress.value,
anchors: studyModuleAnchors.vlog,
},
{
key: 'interview',
progress: getProductionModuleProgress('interview', interview.viewed),
anchors: studyModuleAnchors.interview
progress: interviewProgress.value,
anchors: studyModuleAnchors.interview,
},
{
key: 'film',
progress: getProductionModuleProgress('film', film.viewed),
anchors: studyModuleAnchors.film
}
progress: filmProgress.value,
anchors: studyModuleAnchors.film,
},
])
const handleClose = () => {
@@ -95,10 +137,14 @@ const handleClose = () => {
:style="{ textShadow: totalTextShadow }"
>
<span class="text-[clamp(36px,4.5vw,72px)] leading-none">总进度</span>
<span class="text-[clamp(36px,4.5vw,72px)] leading-none">{{ totalProgress }}%</span>
<span class="text-[clamp(36px,4.5vw,72px)] leading-none">
{{ totalProgress }}%
</span>
</div>
<div class="w-[1580px] mx-auto mt-[215px] flex flex-col pr-16 pl-64 gap-2">
<div
class="w-[1580px] mx-auto mt-[215px] flex flex-col pr-16 pl-64 gap-2"
>
<AnchoredProgressBar
v-for="item in progressItems"
:key="item.key"
+82 -21
View File
@@ -1,41 +1,102 @@
import { moduleStepMap } from '@/config/moduleStepMap'
type ProductionModuleKey = 'vlog' | 'interview' | 'film'
export type ProductionModuleKey = 'vlog' | 'interview' | 'film'
type StepKey = 1 | 2 | 3
const countViewedRoutes = (routes: readonly string[], viewed: Record<string, boolean>) => {
const countViewedRoutes = (
routes: readonly string[],
viewed: Record<string, boolean>,
) => {
if (routes.length === 0) return 0
return routes.filter(routeName => viewed[routeName]).length
return routes.filter((routeName) => viewed[routeName]).length
}
const getStepProgress = (routes: readonly string[], viewed: Record<string, boolean>) => {
const getStepProgress = (
routes: readonly string[],
viewed: Record<string, boolean>,
) => {
if (routes.length === 0) return 0
return countViewedRoutes(routes, viewed) / routes.length
}
const interpolate = (start: number, end: number, progress: number) =>
start + (end - start) * progress
const WORK_COMPLETION_KEYS = ['case', 'profession', 'idea'] as const
const getWorkProgress = (workVideosWatched: Record<string, boolean>) =>
countViewedRoutes(WORK_COMPLETION_KEYS, workVideosWatched) /
WORK_COMPLETION_KEYS.length
export const getProductionModuleProgress = (
moduleKey: ProductionModuleKey,
viewed: Record<string, boolean>
viewed: Record<string, boolean>,
workVideosWatched: Record<string, boolean> = {},
) => {
const stepRoutes = moduleStepMap[moduleKey]
const hasAnyViewed = Object.values(stepRoutes).some(routes =>
routes.some(routeName => viewed[routeName])
)
const taskProgress = getWorkProgress(workVideosWatched) * 20
const stepProgress = (step: StepKey) =>
getStepProgress(stepRoutes[step], viewed)
const step1Progress = stepProgress(1) * 22
const step2Progress = stepProgress(2) * 22
const step3Progress = stepProgress(3) * 21
const progress = taskProgress + step1Progress + step2Progress + step3Progress
if (!hasAnyViewed) return 0
if (
progress >= 85 &&
step1Progress === 22 &&
step2Progress === 22 &&
step3Progress === 21
) {
return 100
}
const stepProgress = (step: StepKey) => getStepProgress(stepRoutes[step], viewed)
const step1Progress = stepProgress(1)
const step2Progress = stepProgress(2)
const step3Progress = stepProgress(3)
if (step1Progress < 1) return Math.round(interpolate(20, 42, step1Progress))
if (step2Progress < 1) return Math.round(interpolate(42, 64, step2Progress))
if (step3Progress < 1) return Math.round(interpolate(64, 85, step3Progress))
return 100
return Math.round(progress)
}
const BASIC_ELEMENTS_VIDEO_KEYS = [
'basic-composition:step1',
'basic-composition:step2',
'basic-light:step2',
'basic-light:step3',
'basic-light:step4',
'basic-camera-movement:step2',
'basic-camera-movement:step3',
'basic-shooting-skills:step2',
'basic-shooting-skills:step3',
'basic-shooting-skills:step4',
'basic-shooting-skills:step5',
'basic-shooting-skills:step6',
'basic-shooting-skills:step7',
'basic-short-video-editing-skills:step2',
'basic-short-video-editing-skills:step3',
'basic-short-video-editing-skills:step4',
'basic-short-video-editing-skills:step5',
'basic-short-video-editing-skills:step6',
'basic-short-video-editing-skills:step7',
'basic-short-video-editing-skills:step8',
'basic-short-video-editing-skills:step9',
'basic-short-video-editing-skills:step10',
'basic-short-video-editing-skills:step11',
] as const
export const getBasicElementsProgress = (
unlockProgress: number,
isUnlocked: boolean,
learningVideosWatched: Record<string, boolean> = {},
) => {
if (isUnlocked) return 100
const watchedProgress =
(countViewedRoutes(BASIC_ELEMENTS_VIDEO_KEYS, learningVideosWatched) /
BASIC_ELEMENTS_VIDEO_KEYS.length) *
100
return Math.round(
Math.max(0, Math.min(100, Math.max(unlockProgress, watchedProgress))),
)
}
export const getOverallStudyProgress = (progresses: readonly number[]) => {
if (progresses.length === 0) return 0
const total = progresses.reduce((sum, progress) => sum + progress, 0)
return Math.round(total / progresses.length)
}
+152 -39
View File
@@ -1,46 +1,121 @@
<script lang="ts" setup>
import { nextTick, ref } from 'vue'
import { useRouter } from 'vue-router'
import { computed, nextTick, ref } from 'vue'
import { onBeforeRouteLeave, useRouter } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue'
import QuestionQuiz, {
type Question,
} from '@/components/QuestionQuiz/QuestionQuiz.vue'
import StepNavigation from '@/components/Guide/StepNavigation.vue'
import { IMAGE_PATHS } from '@/utils'
import UnfinishedLearningPrompt from '@/components/UnfinishedLearningPrompt'
import { IMAGE_PATHS, getPublicPath } from '@/utils'
import { Video } from '@/components'
import { useInterviewStore } from '@/stores'
const router = useRouter()
import videoBasic from '/video/vlog_zhizuobianjiaojing_jichujianji.mp4'
import videoSoundeffect from '/video/vlog_renwuqujing_yingxiaochuli.mp4'
import videoPackaging from '/video/vlog_renwuqujing_baozhuang.mp4'
import videoCopyright from '/video/vlog_renwuqujing_banquanguifan.mp4'
const videoList = [videoBasic, videoSoundeffect, videoPackaging, videoCopyright]
const interview = useInterviewStore()
const navigationSteps = [
{
id: 'step1',
id: 'interview-editing-basic-flow',
label: '后期制作',
content: '声画剪辑流程',
videoUrl: getPublicPath('video/interview/editing/ftmk_shjjlc_batch.mp4'),
type: 'post' as const,
},
{
id: 'step2',
id: 'interview-editing-j-cut-l-cut',
label: '后期制作',
content: 'J-cut/L-cut运用',
videoUrl: getPublicPath('video/interview/editing/ftmk_jlcutyy_batch.mp4'),
type: 'post' as const,
},
{
id: 'step3',
id: 'interview-editing-rhythm',
label: '后期制作',
content: '【Q3】节奏剪辑技巧',
content: '节奏剪辑技巧',
videoUrl: getPublicPath('video/interview/editing/ftmk_jzjjjq_batch.mp4'),
type: 'post' as const,
},
]
const currentVideoIndex = ref(0)
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: '所有镜头长度完全一致' },
{ id: 'd', label: '景别远近变化' },
{ id: 'e', label: '字幕出现节奏变化' },
],
correctOptionId: '',
correctOptionIds: ['a', 'b', 'd', 'e'],
isMultiple: true,
explanation: '节奏来自变化与对比,如果所有镜头都一样长,反而容易单调。',
},
]
}
const showStepNavigation = ref(false)
const showUnfinishedPrompt = ref(false)
const pendingLeavePath = ref<string | null>(null)
const allowRouteLeave = ref(false)
const currentVideo = computed(
() => navigationSteps[currentVideoIndex.value].videoUrl,
)
const completedNavigationSteps = computed(() =>
navigationSteps.map(
(step) => interview.professionVideosWatched[step.id] === true,
),
)
const hasUnfinishedLearningVideos = computed(() =>
completedNavigationSteps.value.some((completed) => !completed),
)
const markCurrentVideoWatched = () => {
const videoKey = navigationSteps[currentVideoIndex.value]?.id
if (videoKey) interview.markEditingVideoWatched(videoKey)
}
const handleQuestionsComplete = () => {
markCurrentVideoWatched()
showQuiz.value = false
if (currentVideoIndex.value >= navigationSteps.length - 1) {
playing.value = false
return
}
currentVideoIndex.value++
nextTick(() => {
playing.value = true
})
}
const playNextVideo = () => {
if (currentVideoIndex.value >= videoList.length - 1) {
// “节奏剪辑技巧”(index 2)播完后先出题,答完才算完成并切下一个
if (currentVideoIndex.value === 2) {
playing.value = false
questions.value = getQuestionsForVideo()
showQuiz.value = true
return
}
markCurrentVideoWatched()
if (currentVideoIndex.value >= navigationSteps.length - 1) {
playing.value = false
return
}
@@ -52,8 +127,8 @@ const playNextVideo = () => {
}
const handleStepClick = (index: number) => {
showQuiz.value = false
currentVideoIndex.value = index
// showStepNavigation.value = false
nextTick(() => {
playing.value = true
})
@@ -62,48 +137,86 @@ const handleStepClick = (index: number) => {
const handleClose = () => {
router.back()
}
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="relative w-full h-full overflow-hidden bg-black">
<Video
:src="videoList[currentVideoIndex]"
:src="currentVideo"
v-model="playing"
:muted="false"
:volume="1"
:controls="true"
:hideNativeControls="true"
@ended="playNextVideo"
/>
<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 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-blue-500/80 text-white transition-all duration-200 hover:bg-blue-500"
title="步骤导航"
@click="showStepNavigation = true"
>
<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"
<template #overlay>
<ImgButton
class="absolute right-0 top-0 z-10 pointer-events-auto"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
/>
</svg>
</button>
<button
class="absolute right-4 top-1/2 z-10 -translate-y-1/2 flex h-12 w-12 items-center justify-center rounded-full bg-blue-500/80 pointer-events-auto text-white transition-all duration-200 hover:bg-blue-500"
title="步骤导航"
@click="showStepNavigation = true"
>
<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>
<QuestionQuiz
v-if="showQuiz"
:show-question-number="false"
:questions="questions"
@complete="handleQuestionsComplete"
/>
</template>
</Video>
<StepNavigation
:visible="showStepNavigation"
:background-image="''"
:steps="navigationSteps"
:current-step-index="currentVideoIndex"
:completed-steps="completedNavigationSteps"
@step-click="handleStepClick"
@close="showStepNavigation = false"
/>
<UnfinishedLearningPrompt
:visible="showUnfinishedPrompt"
@continue="handleContinueLearning"
@leave="handleConfirmLeave"
/>
</div>
</template>
+4 -4
View File
@@ -92,8 +92,8 @@ const menuItems = ref([
disabled: false,
},
{
label: '音频',
routeName: 'InterviewAudio',
label: '音效',
routeName: 'InterviewSoundEffect',
parentValue: 3,
disabled: false,
},
@@ -188,8 +188,8 @@ const handleItemClick = (item: { routeName: string; disabled: boolean }) => {
class="w-10"
:src="
isViewed(item.routeName)
? '/src/assets/images/midterm/完成.png'
: '/src/assets/images/midterm/未完成.png'
? getStaticImage('midterm/完成.png')
: getStaticImage('midterm/未完成.png')
"
alt=""
/>
+66 -20
View File
@@ -5,7 +5,7 @@ import { ImgButton } from '@/components/Button'
import { computed, ref, watch, type Ref } from 'vue'
import { ProgressBar, Overlay } from '@/components'
import { GuideSteps, type GuideStep } from '@/components/Guide'
import { GuideSteps, GuideStepsItem, type GuideStep } from '@/components/Guide'
import { useRoute, useRouter } from 'vue-router'
import { useInterviewStore } from '@/stores'
import InterviewMenu from './components/menu.vue'
@@ -14,14 +14,26 @@ const route = useRoute()
const router = useRouter()
const interview = useInterviewStore()
const showReturnGuide = ref(route.query.guide === 'return' && interview.isGuideInterviewReturn)
const showReturnGuide = ref(
route.query.guide === 'return' && interview.isGuideInterviewReturn,
)
// 实战操作台一次性引导(currentStep === 4 时解锁)
const practiceGuideKey = 'interview-practice-guide'
const hasShownPracticeGuide = computed(
() => interview.guideShown[practiceGuideKey] === true,
)
const showPracticeGuide = computed(
() => currentStep.value === 4 && !hasShownPracticeGuide.value,
)
const returnSteps: GuideStep[] = [
{
id: 'return-step1',
content: '接下来,我们将进入真实项目流程,体验一条人物访谈作品是如何一步步完成的。',
audioSrc: getPublicPath('audio/py_3_007.MP3')
}
content:
'接下来,我们将进入真实项目流程,体验一条人物访谈作品是如何一步步完成的。',
audioSrc: getPublicPath('audio/py_3_007.MP3'),
},
]
const handleReturnGuideComplete = () => {
@@ -30,6 +42,10 @@ const handleReturnGuideComplete = () => {
router.replace({ name: 'Interview' })
}
const handlePracticeGuideComplete = () => {
interview.markGuideShown(practiceGuideKey)
}
const stageGuideMap = {
2: {
flagKey: 'isGuideInterviewStage2',
@@ -38,14 +54,15 @@ const stageGuideMap = {
{
id: 'stage2-step1',
content: '前期准备阶段,我们已经完成了主题确认、脚本结构与镜头规划。',
audioSrc: getPublicPath('audio/py_3_010.MP3')
audioSrc: getPublicPath('audio/py_3_010.MP3'),
},
{
id: 'stage2-step2',
content: '进入中期拍摄后就要按照既定方案,把后期需要用的素材尽可能拍完整。',
audioSrc: getPublicPath('audio/py_3_011.MP3')
}
]
content:
'进入中期拍摄后就要按照既定方案,把后期需要用的素材尽可能拍完整。',
audioSrc: getPublicPath('audio/py_3_011.MP3'),
},
],
},
3: {
flagKey: 'isGuideInterviewStage3',
@@ -54,17 +71,17 @@ const stageGuideMap = {
{
id: 'stage3-step1',
content: '拍摄完成啦!现在我们正式进入剪辑环节。',
audioSrc: getPublicPath('audio/py_3_012.MP3')
}
]
}
audioSrc: getPublicPath('audio/py_3_012.MP3'),
},
],
},
} as const
type StageKey = keyof typeof stageGuideMap
const stageGuideStep = ref<StageKey | null>(null)
const stageGuideSteps = computed(() =>
stageGuideStep.value ? stageGuideMap[stageGuideStep.value].steps : []
stageGuideStep.value ? stageGuideMap[stageGuideStep.value].steps : [],
) as Ref<GuideStep[]>
const triggerStageGuide = (step: number) => {
@@ -84,7 +101,7 @@ const handleStageGuideComplete = () => {
const visible = computed({
get: () => interview.interviewMenuVisible,
set: value => interview.setInterviewMenuVisible(value)
set: (value) => interview.setInterviewMenuVisible(value),
})
const currentStep = computed(() => interview.InterviewUnlockProgress)
@@ -111,6 +128,16 @@ const handleClose = () => {
router.push({ name: 'Action' })
}
watch(
showPracticeGuide,
(val) => {
if (val) {
interview.markGuideShown(practiceGuideKey)
}
},
{ immediate: true },
)
watch(
[visible, currentStep],
([isVisible, step]) => {
@@ -118,7 +145,7 @@ watch(
triggerStageGuide(step)
}
},
{ immediate: true }
{ immediate: true },
)
</script>
@@ -161,22 +188,41 @@ watch(
<!-- 任务取景按钮 -->
<div
class="absolute left-24 top-24"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.TASK_CAMERA) }"
:style="{
backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.TASK_CAMERA),
}"
>
<ImgButton
:image="currentStep === 4 ? IMAGE_PATHS.VLOG.TASK_BTN_OK : IMAGE_PATHS.VLOG.TASK_BTN"
:image="
currentStep > 0
? IMAGE_PATHS.VLOG.TASK_BTN_OK
: IMAGE_PATHS.VLOG.TASK_BTN
"
@click="handleWorkClick"
/>
</div>
<GuideSteps
:visible="showPracticeGuide"
highlight-selector=".right-section"
position-offset-y="50%"
@complete="handlePracticeGuideComplete"
>
<GuideStepsItem>点击【实战操作台】进入实战操作!</GuideStepsItem>
</GuideSteps>
<!-- 引导任务 -->
<ImgButton
v-if="currentStep !== 4"
v-show="!showReturnGuide"
class="absolute right-24 bottom-24"
:image="IMAGE_PATHS.VLOG.TASK_PERSON"
/>
<Overlay content-class="w-full h-full" :visible="visible && currentStep < 4">
<Overlay
content-class="w-full h-full"
:visible="visible && currentStep < 4"
>
<!-- 关闭按钮 -->
<ImgButton
class="absolute right-0 top-0 z-20"
@@ -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>
+12 -1
View File
@@ -67,6 +67,17 @@ const worksList = ref<WorkItem[]>([
// }
])
const getCachedWorkScore = (work: WorkItem): number => {
const scores = interviewStore.practiceWorkRatings[work.path]
const values = scores ? Object.values(scores) : []
if (values.length === 0) return work.stars
return Math.round(
values.reduce((total, score) => total + score, 0) / values.length,
)
}
/** 左滑动作品列表 */
const scrollWorksLeft = (): void => {
if (worksScrollIndex.value > 0) {
@@ -290,7 +301,7 @@ onUnmounted(() => {
</h3>
<!-- 星星评分 -->
<StarRating
:score="work.stars"
:score="getCachedWorkScore(work)"
size="lg"
readonly
/>
@@ -1,19 +1,37 @@
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { computed, onBeforeUnmount, ref } from 'vue'
import { useRouter } from 'vue-router'
import { getBackgroundImage, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button'
import PracticeRatingModal, {
type PracticeRatingItem,
} from '@/components/PracticeRatingModal'
import { useInterviewStore } from '@/stores/modules/interview'
const router = useRouter()
const store = useInterviewStore()
type PracticeRatingScores = Record<string, number>
const applyScores = (
items: PracticeRatingItem[],
scores: PracticeRatingScores,
): PracticeRatingItem[] =>
items.map((item) => ({
...item,
score: scores[item.title] ?? item.score,
}))
const toScores = (items: PracticeRatingItem[]): PracticeRatingScores =>
Object.fromEntries(items.map((item) => [item.title, item.score]))
/** 笔记内容 */
const noteContent = ref<string>('')
const noteContent = ref<string>(store.practiceNoteContent)
/** 保存弹窗 */
const showModal = ref<boolean>(false)
const showSuccess = ref<boolean>(false)
let successTimer: number | undefined
/** 返回上一页 */
const handleBack = (): void => {
@@ -23,7 +41,7 @@ const handleBack = (): void => {
const disabled = computed<boolean>(() => noteContent.value.trim() === '')
/** 评估项目列表 */
const evaluationItems = ref<PracticeRatingItem[]>([
const defaultEvaluationItems: PracticeRatingItem[] = [
{
title: '人物表达',
description: '能清晰介绍人物身份、特点或故事,让观众认识人物',
@@ -54,11 +72,28 @@ const evaluationItems = ref<PracticeRatingItem[]>([
description: '作品完整,有真实感与感染力,具备观看吸引力',
score: 5,
},
])
]
const evaluationItems = ref<PracticeRatingItem[]>(
applyScores(defaultEvaluationItems, store.practiceSelfEvaluationScores),
)
const handleSave = (): void => {
console.log('保存笔记')
handleBack()
store.setPracticeNoteContent(noteContent.value)
showSuccess.value = true
window.clearTimeout(successTimer)
successTimer = window.setTimeout(() => {
showSuccess.value = false
}, 1500)
}
const handleSaveEvaluation = (items: PracticeRatingItem[]): void => {
store.setPracticeSelfEvaluationScores(toScores(items))
}
onBeforeUnmount(() => {
window.clearTimeout(successTimer)
})
</script>
<template>
@@ -81,6 +116,13 @@ const handleSave = (): void => {
placeholder="点击输入创作笔记"
></textarea>
<div
v-if="showSuccess"
class="absolute left-1/2 top-1/2 z-[100] -translate-x-1/2 -translate-y-1/2 rounded-2xl bg-black/70 px-16 py-8 text-5xl font-bold text-white"
>
提交成功
</div>
<div class="absolute bottom-24 left-1/2 -translate-x-1/2 flex gap-60">
<ImgButton
image="vlog/practice/保存"
@@ -95,10 +137,11 @@ const handleSave = (): void => {
<PracticeRatingModal
:visible="showModal"
:items="evaluationItems"
v-model:items="evaluationItems"
background-image="interview/practice/人物访谈短片创作-作品评分表"
top-tip="评分时要关注6个维度哦~分别是人物表达、素材拍摄、镜头语言、剪辑结构、音画关系、综合观感每个维度满分5分,能打1-5分,记得对照作品表现来打分呀~"
bottom-tip="己评价的实战作品,从封面入口就能直接看到具体评分!打了多少分一目了然,不用再点进详情页翻找啦~"
@save="handleSaveEvaluation"
@close="showModal = false"
/>
</div>
+34 -4
View File
@@ -6,9 +6,11 @@ import PracticeRatingModal, {
type PracticeRatingItem,
} from '@/components/PracticeRatingModal'
import { IMAGE_PATHS, getPublicPath } from '@/utils'
import { ref } from 'vue'
import { useInterviewStore } from '@/stores/modules/interview'
import { computed, ref } from 'vue'
const router = useRouter()
const store = useInterviewStore()
const props = defineProps({
path: {
@@ -17,8 +19,24 @@ const props = defineProps({
},
})
type PracticeRatingScores = Record<string, number>
const workKey = computed<string>(() => decodeURIComponent(props.path))
const applyScores = (
items: PracticeRatingItem[],
scores: PracticeRatingScores,
): PracticeRatingItem[] =>
items.map((item) => ({
...item,
score: scores[item.title] ?? item.score,
}))
const toScores = (items: PracticeRatingItem[]): PracticeRatingScores =>
Object.fromEntries(items.map((item) => [item.title, item.score]))
/** 评估项目列表 */
const evaluationItems = ref<PracticeRatingItem[]>([
const defaultEvaluationItems: PracticeRatingItem[] = [
{
title: '人物表达',
description: '能清晰介绍人物身份、特点或故事,让观众认识人物',
@@ -49,7 +67,18 @@ const evaluationItems = ref<PracticeRatingItem[]>([
description: '作品完整,有真实感与感染力,具备观看吸引力',
score: 5,
},
])
]
const evaluationItems = ref<PracticeRatingItem[]>(
applyScores(
defaultEvaluationItems,
store.getPracticeWorkRating(workKey.value),
),
)
const handleSaveEvaluation = (items: PracticeRatingItem[]): void => {
store.setPracticeWorkRating(workKey.value, toScores(items))
}
/** 保存弹窗 */
const showModal = ref<boolean>(false)
@@ -82,10 +111,11 @@ const handleBack = (): void => {
<PracticeRatingModal
:visible="showModal"
:items="evaluationItems"
v-model:items="evaluationItems"
background-image="interview/practice/人物访谈短片创作-作品评分表"
top-tip="评分时要关注6个维度哦~分别是人物表达、素材拍摄、镜头语言、剪辑结构、音画关系、综合观感每个维度满分5分,能打1-5分,记得对照作品表现来打分呀~"
bottom-tip="己评价的实战作品,从封面入口就能直接看到具体评分!打了多少分一目了然,不用再点进详情页翻找啦~"
@save="handleSaveEvaluation"
@close="showModal = false"
/>
</div>
+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>
+4 -1
View File
@@ -21,7 +21,10 @@ const interview = useInterviewStore()
type WorkTab = 'vlog' | 'case' | 'profession' | 'idea'
const selected = ref<WorkTab>('case')
const selected = computed({
get: () => interview.workSelectedTab as WorkTab,
set: (value: WorkTab) => interview.setWorkSelectedTab(value),
})
const workSideSwitcherRef = ref<InstanceType<typeof WorkSideSwitcher>>()
const isGuideInterviewTask = computed(() => interview.isGuideInterviewTask)
+56 -9
View File
@@ -1,9 +1,10 @@
<script lang="ts" setup>
import { computed, nextTick, ref } from 'vue'
import { useRouter } from 'vue-router'
import { onBeforeRouteLeave, useRouter } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import StepNavigation from '@/components/Guide/StepNavigation.vue'
import UnfinishedLearningPrompt from '@/components/UnfinishedLearningPrompt'
import { getPublicPath, IMAGE_PATHS } from '@/utils'
import { Video } from '@/components'
import { useVlogStore } from '@/stores/modules/vlog'
@@ -19,28 +20,28 @@ if (showIntroGuide.value) {
const navigationSteps = ref([
{
id: 'step1',
id: 'vlog-editing-start',
label: '后期制作',
content: '剪辑起点',
videoUrl: getPublicPath('video/vlog/editing/Vlogmk_jjqd_batch.mp4'),
type: 'post' as const,
},
{
id: 'step2',
id: 'vlog-editing-audio-picture-match',
label: '后期制作',
content: '音画匹配',
videoUrl: getPublicPath('video/vlog/editing/Vlogmk_yhpp_batch.mp4'),
type: 'post' as const,
},
{
id: 'step3',
id: 'vlog-editing-rhythm',
label: '后期制作',
content: '剪辑节奏',
videoUrl: getPublicPath('video/vlog/editing/Vlogmk_jjjz_batch.mp4'),
type: 'post' as const,
},
{
id: 'step4',
id: 'vlog-editing-sound-transition',
label: '后期制作',
content: '声音与转场',
videoUrl: getPublicPath('video/vlog/editing/Vlogmk_syyzc_batch.mp4'),
@@ -48,16 +49,36 @@ const navigationSteps = ref([
},
])
const currentVideoIndex = ref(0)
const playing = ref(false)
const showStepNavigation = ref(false)
const showUnfinishedPrompt = ref(false)
const pendingLeavePath = ref<string | null>(null)
const allowRouteLeave = ref(false)
const showEndingGuide = ref(false)
const currentVideo = computed(
() => navigationSteps.value[currentVideoIndex.value].videoUrl,
)
const currentVideoIndex = ref(0)
const playing = ref(false)
const showStepNavigation = ref(false)
const showEndingGuide = ref(false)
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.markEditingVideoWatched(videoKey)
}
const playNextVideo = () => {
markCurrentVideoWatched()
if (currentVideoIndex.value >= navigationSteps.value.length - 1) {
playing.value = false
showEndingGuide.value = true
@@ -91,6 +112,25 @@ const handleIntroGuideComplete = () => {
const handleEndingGuideComplete = () => {
showEndingGuide.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>
@@ -131,10 +171,17 @@ const handleEndingGuideComplete = () => {
:background-image="''"
:steps="navigationSteps"
:current-step-index="currentVideoIndex"
:completed-steps="completedNavigationSteps"
@step-click="handleStepClick"
@close="showStepNavigation = false"
/>
<UnfinishedLearningPrompt
:visible="showUnfinishedPrompt"
@continue="handleContinueLearning"
@leave="handleConfirmLeave"
/>
<GuideSteps
v-if="showIntroGuide"
position-offset-y="85%"
+46 -27
View File
@@ -16,18 +16,18 @@ const category = ref([
{
label: '前期准备',
value: 1,
icon: getStaticImage('midterm/准备图标.png')
icon: getStaticImage('midterm/准备图标.png'),
},
{
label: '中期拍摄',
value: 2,
icon: getStaticImage('midterm/拍摄图标.png')
icon: getStaticImage('midterm/拍摄图标.png'),
},
{
label: '后期剪辑',
value: 3,
icon: getStaticImage('midterm/剪辑图标.png')
}
icon: getStaticImage('midterm/剪辑图标.png'),
},
])
const menuItems = ref([
@@ -35,43 +35,43 @@ const menuItems = ref([
label: '资源筹备',
routeName: 'VlogResource',
parentValue: 1,
disabled: false
disabled: false,
},
{
label: '创意构想',
routeName: 'VlogCreative',
parentValue: 1,
disabled: false
disabled: false,
},
{
label: '脚本撰写',
routeName: 'VlogScriptWriting',
parentValue: 1,
disabled: false
disabled: false,
},
{
label: '分镜制作',
routeName: 'VlogStoryboard',
parentValue: 1,
disabled: false
disabled: false,
},
{
label: '设备准备',
routeName: 'VlogMidtermEquipment',
parentValue: 2,
disabled: false
disabled: false,
},
{
label: '实地取景',
routeName: 'VlogMidtermShooting',
parentValue: 2,
disabled: false
disabled: false,
},
{
label: '现场拍摄',
routeName: 'VlogShooting',
parentValue: 2,
disabled: false
disabled: false,
},
// {
// label: '素材管理',
@@ -83,20 +83,20 @@ const menuItems = ref([
label: '剪辑',
routeName: 'VlogEditing',
parentValue: 3,
disabled: false
disabled: false,
},
{
label: '调色',
routeName: 'VlogColor',
parentValue: 3,
disabled: false
disabled: false,
},
{
label: '导出发布',
routeName: 'VlogExportPublish',
parentValue: 3,
disabled: false
}
disabled: false,
},
])
const props = defineProps<{
@@ -117,7 +117,9 @@ const currentParentValue = computed<number>(() => {
const currentItemValue = ref<string>('') // 当前选中的子级菜单项
const getMenuItems = computed(() => {
return menuItems.value.filter(item => item.parentValue === currentParentValue.value)
return menuItems.value.filter(
(item) => item.parentValue === currentParentValue.value,
)
})
const handleItemClick = (item: { routeName: string; disabled: boolean }) => {
@@ -128,13 +130,20 @@ const handleItemClick = (item: { routeName: string; disabled: boolean }) => {
</script>
<template>
<div class="absolute left-20 top-0 bottom-0 w-[520px] pt-28" v-if="currentStep">
<div
class="absolute left-20 top-0 bottom-0 w-[520px] pt-28"
v-if="currentStep"
>
<!-- 分类名称 -->
<div class="absolute top-3 left-24 flex items-center space-x-2">
<span class="text-4xl text-[#fbbf24] font-black">
{{ category[currentParentValue - 1].label }}
</span>
<img class="filter-blue" :src="category[currentParentValue - 1].icon" alt="" />
<img
class="filter-blue"
:src="category[currentParentValue - 1].icon"
alt=""
/>
</div>
<!-- 背景图片 -->
<div
@@ -142,12 +151,17 @@ const handleItemClick = (item: { routeName: string; disabled: boolean }) => {
:style="{ backgroundImage: getBackgroundImage('components/menu-bg.png') }"
></div>
<div class="flex items-center flex-col pl-[60px]">
<template v-for="(item, index) in getMenuItems" :key="item.routeName">
<template
v-for="(item, index) in getMenuItems"
:key="item.routeName"
>
<div
class="relative w-[373px] h-[143px] pl-14 flex items-center justify-center group text-4xl font-black transition-colors"
:class="[
currentItemValue === item.routeName ? 'text-blue-400' : 'text-white',
item.disabled ? 'opacity-20 cursor-not-allowed' : 'cursor-pointer'
currentItemValue === item.routeName
? 'text-blue-400'
: 'text-white',
item.disabled ? 'opacity-20 cursor-not-allowed' : 'cursor-pointer',
]"
:style="{ marginLeft: -index * 40 + 'px' }"
@click="handleItemClick(item)"
@@ -155,16 +169,21 @@ const handleItemClick = (item: { routeName: string; disabled: boolean }) => {
<div
v-if="!item.disabled"
class="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-0 group-hover:opacity-100 transition-opacity duration-300"
:style="{ backgroundImage: getBackgroundImage('components/menu-hover.png') }"
:style="{
backgroundImage: getBackgroundImage('components/menu-hover.png'),
}"
></div>
<span class="flex items-center gap-2" :class="[!item.disabled ? 'menu-glow-text' : '']">
<span
class="flex items-center gap-2"
:class="[!item.disabled ? 'menu-glow-text' : '']"
>
<span>{{ item.label }}</span>
<img
class="w-10"
:src="
isViewed(item.routeName)
? '/src/assets/images/midterm/完成.png'
: '/src/assets/images/midterm/未完成.png'
? getStaticImage('midterm/完成.png')
: getStaticImage('midterm/未完成.png')
"
alt=""
/>
@@ -177,8 +196,8 @@ const handleItemClick = (item: { routeName: string; disabled: boolean }) => {
<style scoped>
.filter-blue {
filter: brightness(0) saturate(100%) invert(79%) sepia(29%) saturate(1190%) hue-rotate(330deg)
brightness(102%) contrast(98%);
filter: brightness(0) saturate(100%) invert(79%) sepia(29%) saturate(1190%)
hue-rotate(330deg) brightness(102%) contrast(98%);
}
.menu-glow-text {
+80 -36
View File
@@ -4,7 +4,7 @@ import { ImgButton } from '@/components/Button'
import { computed, ref, watch, type Ref } from 'vue'
import { ProgressBar, Overlay } from '@/components'
import { GuideSteps, type GuideStep } from '@/components/Guide'
import { GuideSteps, GuideStepsItem, type GuideStep } from '@/components/Guide'
import { useRoute, useRouter } from 'vue-router'
import { useVlogStore } from '@/stores'
import VlogMenu from './components/menu.vue'
@@ -15,32 +15,43 @@ const vlog = useVlogStore()
const visible = computed({
get: () => vlog.vlogMenuVisible,
set: value => vlog.setVlogMenuVisible(value)
set: (value) => vlog.setVlogMenuVisible(value),
})
const currentStep = computed(() => vlog.vlogUnlockProgress)
const progress = computed(() => vlog.progressPercent)
const showReturnGuide = ref(route.query.guide === 'return' && vlog.isGuideVlogReturn)
// 实战操作台一次性引导(currentStep === 4 时解锁)
const practiceGuideKey = 'vlog-practice-guide'
const hasShownPracticeGuide = computed(
() => vlog.guideShown[practiceGuideKey] === true,
)
const showPracticeGuide = computed(
() => currentStep.value === 4 && !hasShownPracticeGuide.value,
)
const showReturnGuide = ref(
route.query.guide === 'return' && vlog.isGuideVlogReturn,
)
const returnSteps: GuideStep[] = [
{
id: 'return-step1',
content: '通过前面的案例赏析,相信你已经初步了解了 Vlog 的表达方式。',
audioSrc: getPublicPath('audio/py_2_006.MP3')
audioSrc: getPublicPath('audio/py_2_006.MP3'),
},
{
id: 'return-step2',
content: '接下来,我们将一起完成一支真实主题作品——《我的志愿者手记》。',
audioSrc: getPublicPath('audio/py_2_007.MP3')
content: '接下来,我们将一起完成真实主题作品——《我的志愿者手记》。',
audioSrc: getPublicPath('audio/py_2_007.MP3'),
},
{
id: 'return-step3',
content:
'在记录社区志愿活动的过程中,学习 Vlog 从前期策划、中期拍摄到后期剪辑的完整流程,同时传递热心助人、服务社会的价值力量。',
audioSrc: getPublicPath('audio/py_2_008.MP3')
}
audioSrc: getPublicPath('audio/py_2_008.MP3'),
},
]
const handleReturnGuideComplete = () => {
@@ -58,20 +69,20 @@ const stageGuideMap = {
{
id: 'stage1-step1',
content: '一次顺利的拍摄,往往从充分准备开始。',
audioSrc: getPublicPath('audio/py_2_009.MP3')
audioSrc: getPublicPath('audio/py_2_009.MP3'),
},
{
id: 'stage1-step2',
content:
'前期阶段,我们需要完成四项关键任务:【资源筹备】【创意构想】【脚本撰写】【分镜制作】',
audioSrc: getPublicPath('audio/py_2_010.MP3')
'前期阶段,我们需要完成四项关键任务:\n【资源筹备】【创意构想】【脚本撰写】【分镜制作】',
audioSrc: getPublicPath('audio/py_2_010.MP3'),
},
{
id: 'stage1-step3',
content: '画面中高亮的按钮是我们可以展开学习的内容。点击进入学习吧!',
audioSrc: getPublicPath('audio/py_2_011.MP3')
}
]
// {
// id: 'stage1-step3',
// content: '画面中高亮的按钮是我们可以展开学习的内容。点击进入学习吧!',
// audioSrc: getPublicPath('audio/py_2_011.MP3'),
// },
],
},
2: {
flagKey: 'isGuideVlogStage2',
@@ -80,10 +91,10 @@ const stageGuideMap = {
{
id: 'stage2-step1',
content:
'拍摄过程可分为三个核心步骤:【设备准备】【实地取景】【现场拍摄】。稳妥的准备是顺利拍摄的前提。',
audioSrc: getPublicPath('audio/py_2_012.MP3')
}
]
'拍摄过程可分为三个核心步骤:\n【设备准备】【实地取景】【现场拍摄】。稳妥的准备是顺利拍摄的前提。',
audioSrc: getPublicPath('audio/py_2_012.MP3'),
},
],
},
3: {
flagKey: 'isGuideVlogStage3',
@@ -91,28 +102,30 @@ const stageGuideMap = {
steps: [
{
id: 'stage3-step1',
content: '经过一系列的准备与拍摄,现在我们来到后期剪辑阶段。',
audioSrc: getPublicPath('audio/py_2_016.MP3')
content: '经过一系列的准备与拍摄,我们来到后期剪辑阶段。',
audioSrc: getPublicPath('audio/py_2_016.MP3'),
},
{
id: 'stage3-step2',
content: '这个阶段需要做好四件事:【素材整理】【剪辑】【调色】【导出发布】。',
audioSrc: getPublicPath('audio/py_2_017.MP3')
content:
'这个阶段需要做好四件事:\n【素材整理】【剪辑】【调色】【导出发布】。',
audioSrc: getPublicPath('audio/py_2_017.MP3'),
},
{
id: 'stage3-step3',
content: '画面中高亮的按钮,是可以展开深入学习的内容,点击就能开启学习啦!',
audioSrc: getPublicPath('audio/py_2_018.MP3')
}
]
}
content:
'画面中高亮的按钮,是可以展开深入学习的内容,点击就能开启学习啦!',
audioSrc: getPublicPath('audio/py_2_018.MP3'),
},
],
},
} as const
type StageKey = keyof typeof stageGuideMap
const stageGuideStep = ref<StageKey | null>(null)
const stageGuideSteps = computed(() =>
stageGuideStep.value ? stageGuideMap[stageGuideStep.value].steps : []
stageGuideStep.value ? stageGuideMap[stageGuideStep.value].steps : [],
) as Ref<GuideStep[]>
const triggerStageGuide = (step: number) => {
@@ -152,6 +165,16 @@ const handleClose = () => {
router.push({ name: 'Action' })
}
watch(
showPracticeGuide,
(val) => {
if (val) {
vlog.markGuideShown(practiceGuideKey)
}
},
{ immediate: true },
)
watch(
[visible, currentStep],
([isVisible, step]) => {
@@ -159,7 +182,7 @@ watch(
triggerStageGuide(step)
}
},
{ immediate: true }
{ immediate: true },
)
</script>
@@ -168,7 +191,9 @@ watch(
<!-- 背景图片-center -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.BG_CENTER) }"
:style="{
backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.BG_CENTER),
}"
></div>
<!-- 进度数据 -->
@@ -202,22 +227,41 @@ watch(
<!-- 任务取景按钮 -->
<div
class="absolute left-24 top-24"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.TASK_CAMERA) }"
:style="{
backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.TASK_CAMERA),
}"
>
<ImgButton
:image="currentStep === 4 ? IMAGE_PATHS.VLOG.TASK_BTN_OK : IMAGE_PATHS.VLOG.TASK_BTN"
:image="
currentStep > 0
? IMAGE_PATHS.VLOG.TASK_BTN_OK
: IMAGE_PATHS.VLOG.TASK_BTN
"
@click="handleWorkClick"
/>
</div>
<GuideSteps
:visible="showPracticeGuide"
highlight-selector=".right-section"
position-offset-y="50%"
@complete="vlog.markGuideShown(practiceGuideKey)"
>
<GuideStepsItem>点击【实战操作台】进入实战操作!</GuideStepsItem>
</GuideSteps>
<!-- 引导人物 -->
<ImgButton
v-if="currentStep !== 4"
v-show="!showReturnGuide"
class="absolute right-24 bottom-24"
:image="IMAGE_PATHS.VLOG.TASK_PERSON"
/>
<Overlay content-class="w-full h-full" :visible="visible && currentStep < 4">
<Overlay
content-class="w-full h-full"
:visible="visible && currentStep < 4"
>
<!-- 关闭按钮 -->
<ImgButton
class="absolute right-0 top-0 z-20"
@@ -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>
+12 -1
View File
@@ -68,6 +68,17 @@ const worksList = ref<WorkItem[]>([
},
])
const getCachedWorkScore = (work: WorkItem): number => {
const scores = vlogStore.practiceWorkRatings[work.path]
const values = scores ? Object.values(scores) : []
if (values.length === 0) return work.stars
return Math.round(
values.reduce((total, score) => total + score, 0) / values.length,
)
}
/** 左滑动作品列表 */
const scrollWorksLeft = (): void => {
if (worksScrollIndex.value > 0) {
@@ -291,7 +302,7 @@ onUnmounted(() => {
</h3>
<!-- 星星评分 -->
<StarRating
:score="work.stars"
:score="getCachedWorkScore(work)"
size="lg"
readonly
/>
+50 -7
View File
@@ -1,19 +1,37 @@
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { computed, onBeforeUnmount, ref } from 'vue'
import { useRouter } from 'vue-router'
import { getBackgroundImage, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button'
import PracticeRatingModal, {
type PracticeRatingItem,
} from '@/components/PracticeRatingModal'
import { useVlogStore } from '@/stores/modules/vlog'
const router = useRouter()
const store = useVlogStore()
type PracticeRatingScores = Record<string, number>
const applyScores = (
items: PracticeRatingItem[],
scores: PracticeRatingScores,
): PracticeRatingItem[] =>
items.map((item) => ({
...item,
score: scores[item.title] ?? item.score,
}))
const toScores = (items: PracticeRatingItem[]): PracticeRatingScores =>
Object.fromEntries(items.map((item) => [item.title, item.score]))
/** 笔记内容 */
const noteContent = ref<string>('')
const noteContent = ref<string>(store.practiceNoteContent)
/** 保存弹窗 */
const showModal = ref<boolean>(false)
const showSuccess = ref<boolean>(false)
let successTimer: number | undefined
/** 返回上一页 */
const handleBack = (): void => {
@@ -23,7 +41,7 @@ const handleBack = (): void => {
const disabled = computed<boolean>(() => noteContent.value.trim() === '')
/** 评估项目列表 */
const evaluationItems = ref<PracticeRatingItem[]>([
const defaultEvaluationItems: PracticeRatingItem[] = [
{
title: '开场建立',
description: '能清晰交代时间、地点与事件开端,观众易于进入情境',
@@ -55,11 +73,28 @@ const evaluationItems = ref<PracticeRatingItem[]>([
description: '视频完整、有连贯观看体验,能表达出一次真实记录或个人感受',
score: 5,
},
])
]
const evaluationItems = ref<PracticeRatingItem[]>(
applyScores(defaultEvaluationItems, store.practiceSelfEvaluationScores),
)
const handleSave = (): void => {
console.log('保存笔记')
handleBack()
store.setPracticeNoteContent(noteContent.value)
showSuccess.value = true
window.clearTimeout(successTimer)
successTimer = window.setTimeout(() => {
showSuccess.value = false
}, 1500)
}
const handleSaveEvaluation = (items: PracticeRatingItem[]): void => {
store.setPracticeSelfEvaluationScores(toScores(items))
}
onBeforeUnmount(() => {
window.clearTimeout(successTimer)
})
</script>
<template>
@@ -82,6 +117,13 @@ const handleSave = (): void => {
placeholder="点击输入创作笔记"
></textarea>
<div
v-if="showSuccess"
class="absolute left-1/2 top-1/2 z-[100] -translate-x-1/2 -translate-y-1/2 rounded-2xl bg-black/70 px-16 py-8 text-5xl font-bold text-white"
>
提交成功
</div>
<div class="absolute bottom-24 left-1/2 -translate-x-1/2 flex gap-60">
<ImgButton
image="vlog/practice/保存"
@@ -96,10 +138,11 @@ const handleSave = (): void => {
<PracticeRatingModal
:visible="showModal"
:items="evaluationItems"
v-model:items="evaluationItems"
background-image="vlog/practice/Vlog作品评分表"
top-tip="评分时要关注6个维度哦~分别是开场建立、拍摄表达、真实捕捉、剪辑结构、音画关系、综合感受,每个维度满分5分,能打1-5分,记得对照作品表现来打分呀~"
bottom-tip="己评价的实战作品,从封面入口就能直接看到具体评分!打了多少分一目了然,不用再点进详情页翻找啦~"
@save="handleSaveEvaluation"
@close="showModal = false"
/>
</div>
+34 -4
View File
@@ -6,9 +6,11 @@ import PracticeRatingModal, {
type PracticeRatingItem,
} from '@/components/PracticeRatingModal'
import { IMAGE_PATHS, getPublicPath } from '@/utils'
import { ref } from 'vue'
import { useVlogStore } from '@/stores/modules/vlog'
import { computed, ref } from 'vue'
const router = useRouter()
const store = useVlogStore()
const props = defineProps({
path: {
@@ -17,8 +19,24 @@ const props = defineProps({
},
})
type PracticeRatingScores = Record<string, number>
const workKey = computed<string>(() => decodeURIComponent(props.path))
const applyScores = (
items: PracticeRatingItem[],
scores: PracticeRatingScores,
): PracticeRatingItem[] =>
items.map((item) => ({
...item,
score: scores[item.title] ?? item.score,
}))
const toScores = (items: PracticeRatingItem[]): PracticeRatingScores =>
Object.fromEntries(items.map((item) => [item.title, item.score]))
/** 评估项目列表 */
const evaluationItems = ref<PracticeRatingItem[]>([
const defaultEvaluationItems: PracticeRatingItem[] = [
{
title: '开场建立',
description: '能清晰交代时间、地点与事件开端,观众易于进入情境',
@@ -50,7 +68,18 @@ const evaluationItems = ref<PracticeRatingItem[]>([
description: '视频完整、有连贯观看体验,能表达出一次真实记录或个人感受',
score: 5,
},
])
]
const evaluationItems = ref<PracticeRatingItem[]>(
applyScores(
defaultEvaluationItems,
store.getPracticeWorkRating(workKey.value),
),
)
const handleSaveEvaluation = (items: PracticeRatingItem[]): void => {
store.setPracticeWorkRating(workKey.value, toScores(items))
}
/** 保存弹窗 */
const showModal = ref<boolean>(false)
@@ -83,10 +112,11 @@ const handleBack = (): void => {
<PracticeRatingModal
:visible="showModal"
:items="evaluationItems"
v-model:items="evaluationItems"
background-image="vlog/practice/Vlog作品评分表"
top-tip="评分时要关注6个维度哦~分别是开场建立、拍摄表达、真实捕捉、剪辑结构、音画关系、综合感受,每个维度满分5分,能打1-5分,记得对照作品表现来打分呀~"
bottom-tip="己评价的实战作品,从封面入口就能直接看到具体评分!打了多少分一目了然,不用再点进详情页翻找啦~"
@save="handleSaveEvaluation"
@close="showModal = false"
/>
</div>
+82 -9
View File
@@ -1,9 +1,10 @@
<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 } from '@/components/Guide'
import StepNavigation from '@/components/Guide/StepNavigation.vue'
import UnfinishedLearningPrompt from '@/components/UnfinishedLearningPrompt'
import { Video } from '@/components'
import {
getBackgroundImage,
@@ -86,11 +87,46 @@ const navigationSteps = ref([
// 步骤导航相关状态
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 = [
'vlog-profession-jingbie',
'vlog-profession-jiaoben',
'vlog-profession-sucaijianji',
'vlog-profession-yinyue',
'vlog-profession-shitingyuyan',
] as const
const completedNavigationSteps = computed(() =>
navigationSteps.value.map(
(_, index) =>
vlog.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
vlog.markProfessionVideoWatched(videoKey)
const isAllWatched = professionVideoKeys.every(
(key) => key === videoKey || vlog.professionVideosWatched[key] === true,
)
if (isAllWatched) vlog.markWorkVideoWatched('profession')
}
//按顺序播放
const currentVideo = computed(
() => navigationSteps.value[currentVideoIndex.value].videoUrl,
@@ -106,13 +142,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(() => {
@@ -123,6 +160,7 @@ const handleVideoChange = () => {
const handleClose = () => {
if (key.value === 'idea') {
vlog.markWorkVideoWatched('idea')
if (vlog.vlogUnlockProgress === 0) vlog.setVlogUnlockProgress(1)
router.push({
name: 'Vlog',
@@ -150,11 +188,20 @@ const label = computed(() => route.query.label as string)
// ])
const onEnded = () => {
if (key.value === 'profession') handleVideoChange()
if (key.value === 'case') {
vlog.markWorkVideoWatched('case')
return
}
if (key.value === 'profession') {
markCurrentProfessionVideoWatched()
handleVideoChange()
}
}
const handleComplete = () => {
if (key.value === 'profession') {
vlog.markWorkVideoWatched('profession')
if (!vlog.isVlogUnlock) vlog.setIsVlogUnlock(true)
router.push({
name: 'VlogWork',
@@ -175,6 +222,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') {
steps.value = ideaSteps
@@ -325,9 +391,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>
+5 -2
View File
@@ -21,7 +21,10 @@ const vlog = useVlogStore()
type WorkTab = 'vlog' | 'case' | 'profession' | 'idea'
const selected = ref<WorkTab>('case')
const selected = computed({
get: () => vlog.workSelectedTab as WorkTab,
set: (value: WorkTab) => vlog.setWorkSelectedTab(value),
})
const workSideSwitcherRef = ref<InstanceType<typeof WorkSideSwitcher>>()
const isGuideVlogTask = computed(() => vlog.isGuideVlogTask)
@@ -45,7 +48,7 @@ const steps = [
{
id: 'step3',
content:
'接下来,我们将通过一个具体案例,一起看看 Vlog 是如何发现生活中的美好、记录生活中的温度。',
'接下来,我们通过一个具体案例,一起看看Vlog是如何记录生活中的美好的。',
audioSrc: getPublicPath('audio/py_2_003.MP3'),
highlightSelector: '.img-switch-item--case',
},