feat: 完善课程学习与技能树体验

完善课程资源、学习进度与技能树解锁体验,统一视频播放与引导相关交互。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
zhongyidianandClaude Opus 4.7 committed 2026-05-24 10:30:18 +08:00
1 parent 6f289959be
commit 3e258b87d9
314 files changed
+7020 -3386

No files matched your search

@@ -9,7 +9,12 @@ import { Video } from '@/components'
import QuestionQuiz, {
type Question,
} from '@/components/QuestionQuiz/QuestionQuiz.vue'
import { getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
import {
getPublicPath,
getStaticImage,
IMAGE_PATHS,
getVideoPoster,
} from '@/utils'
import { useBasicElementsStore } from '@/stores/modules/basic-elements'
const router = useRouter()
@@ -62,7 +67,9 @@ const rawSteps = [
id: 'step6',
label: '摄影基础',
content: '神奇的微距摄影',
videoUrl: getPublicPath('video/new/shooting-skills/xsdh_sqdwjsy_batch.mp4'),
videoUrl: getPublicPath(
'video/new/shooting-skills/xinshou_shenqideweijusheying.mp4',
),
type: 'post' as const,
},
{
@@ -222,10 +229,7 @@ const handleVideoChange = () => {
status.value = true
return
}
nextTick(() => {
// 使用受控播放来触发下一个视频自动播放
playing.value = true
})
playing.value = true
}
const handleClose = () => {
@@ -257,9 +261,7 @@ const handleStepClick = (index: number) => {
showQuiz.value = false
currentVideoIndex.value = index
currentNavigationStep.value = index
nextTick(() => {
playing.value = true
})
playing.value = true
}
const handleStepNavigationClose = () => {
@@ -330,6 +332,7 @@ onBeforeRouteLeave((to) => {
<Video
v-if="currentVideo"
:src="currentVideo"
:poster="getVideoPoster(currentVideo)"
v-model="playing"
:muted="false"
:volume="1"
@@ -357,7 +360,6 @@ onBeforeRouteLeave((to) => {
<!-- 步骤导航抽屉 -->
<StepNavigation
:visible="showStepNavigation"
:background-image="getStaticImage('work/vlog背景全屏待定')"
:steps="navigationSteps"
:current-step-index="currentNavigationStep"
:completed-steps="completedNavigationSteps"
@@ -5,7 +5,12 @@ 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 {
getPublicPath,
getStaticImage,
IMAGE_PATHS,
getVideoPoster,
} from '@/utils'
// 运镜初识 - 暂时用运镜的基本方式代替
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { useBasicElementsStore } from '@/stores/modules/basic-elements'
@@ -123,10 +128,7 @@ const handleVideoChange = () => {
return
}
nextTick(() => {
// 使用受控播放来触发下一个视频自动播放
playing.value = true
})
playing.value = true
}
const handleClose = () => {
@@ -146,9 +148,7 @@ const onEnded = () => {
const handleStepClick = (index: number) => {
currentVideoIndex.value = index
currentNavigationStep.value = index
nextTick(() => {
playing.value = true
})
playing.value = true
}
const handleStepNavigationClose = () => {
@@ -205,6 +205,7 @@ onBeforeRouteLeave((to) => {
<Video
:src="currentVideo"
:poster="getVideoPoster(currentVideo)"
v-model="playing"
:muted="false"
:volume="1"
@@ -226,7 +227,6 @@ onBeforeRouteLeave((to) => {
<!-- 步骤导航抽屉 -->
<StepNavigation
:visible="showStepNavigation"
:background-image="getStaticImage('work/vlog背景全屏待定')"
:steps="navigationSteps"
:current-step-index="currentNavigationStep"
:completed-steps="completedNavigationSteps"
@@ -262,7 +262,7 @@ onBeforeRouteLeave((to) => {
当我们将画面稳定之后,就来到了镜头语言。
</GuideStepsItem>
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_017.MP3')">
同一个场景,用不同运镜,表达就不一样。跟着我,去了解5种基础运镜吧~
同一个场景,用不同运镜,表达就不一样。跟着我,去了解5种基础运镜吧!
</GuideStepsItem>
</GuideSteps>
</div>
+10 -12
View File
@@ -5,7 +5,12 @@ 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 {
getPublicPath,
getStaticImage,
IMAGE_PATHS,
getVideoPoster,
} from '@/utils'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { useBasicElementsStore } from '@/stores/modules/basic-elements'
const router = useRouter()
@@ -87,10 +92,7 @@ const handleVideoChange = () => {
status.value = true
return
}
nextTick(() => {
// 使用受控播放来触发下一个视频自动播放
playing.value = true
})
playing.value = true
}
const handleClose = () => {
@@ -110,11 +112,7 @@ const onEnded = () => {
const handleStepClick = (index: number) => {
currentVideoIndex.value = index
currentNavigationStep.value = index
// 先重置播放状态,确保 v-model 触发 watcher
playing.value = false
nextTick(() => {
playing.value = true
})
playing.value = true
}
const handleStepNavigationClose = () => {
@@ -179,6 +177,7 @@ onMounted(() => {})
<Video
:src="currentVideo"
:poster="getVideoPoster(currentVideo)"
v-model="playing"
:muted="false"
:volume="1"
@@ -200,7 +199,6 @@ onMounted(() => {})
<!-- 步骤导航抽屉 -->
<StepNavigation
:visible="showStepNavigation"
:background-image="getStaticImage('work/vlog背景全屏待定')"
:steps="navigationSteps"
:current-step-index="currentNavigationStep"
:completed-steps="completedNavigationSteps"
@@ -232,7 +230,7 @@ onMounted(() => {})
</GuideStepsItem>
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_008.MP3')">
<div class="flex flex-wrap items-center justify-start">
<div>一起来看看吧~</div>
<div>一起来看看吧!</div>
<div class="text-yellow-400">
点击
<button
+9 -9
View File
@@ -8,7 +8,12 @@ import { Video } from '@/components'
import QuestionQuiz, {
type Question,
} from '@/components/QuestionQuiz/QuestionQuiz.vue'
import { getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
import {
getPublicPath,
getStaticImage,
IMAGE_PATHS,
getVideoPoster,
} from '@/utils'
import { GuideSteps } from '@/components/Guide'
import GuideStepsItem from '@/components/Guide/GuideStepsItem.vue'
import { useBasicElementsStore } from '@/stores/modules/basic-elements'
@@ -163,10 +168,7 @@ const handleVideoChange = () => {
return
}
nextTick(() => {
// 使用受控播放来触发下一个视频自动播放
playing.value = true
})
playing.value = true
}
const handleClose = () => {
@@ -198,9 +200,7 @@ const handleStepClick = (index: number) => {
showQuiz.value = false
currentVideoIndex.value = index
currentNavigationStep.value = index
nextTick(() => {
playing.value = true
})
playing.value = true
}
const handleStepNavigationClose = () => {
@@ -264,6 +264,7 @@ onBeforeRouteLeave((to) => {
<Video
:src="currentVideo"
:poster="getVideoPoster(currentVideo)"
v-model="playing"
:muted="false"
:volume="1"
@@ -291,7 +292,6 @@ onBeforeRouteLeave((to) => {
<!-- 步骤导航抽屉 -->
<StepNavigation
:visible="showStepNavigation"
:background-image="getStaticImage('work/vlog背景全屏待定')"
:steps="navigationSteps"
:current-step-index="currentNavigationStep"
:completed-steps="completedNavigationSteps"
@@ -6,7 +6,12 @@ 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 {
getPublicPath,
getStaticImage,
IMAGE_PATHS,
getVideoPoster,
} from '@/utils'
import { useBasicElementsStore } from '@/stores/modules/basic-elements'
const router = useRouter()
@@ -115,7 +120,6 @@ 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开始
@@ -153,13 +157,9 @@ const handleVideoChange = () => {
currentNavigationStep.value = currentVideoIndex.value
} else {
status.value = true
showCompletionGuide.value = true
return
}
nextTick(() => {
// 使用受控播放来触发下一个视频自动播放
playing.value = true
})
playing.value = true
}
const handleClose = () => {
@@ -177,9 +177,7 @@ const onEnded = () => {
const handleStepClick = (index: number) => {
currentVideoIndex.value = index
currentNavigationStep.value = index
nextTick(() => {
playing.value = true
})
playing.value = true
}
const handleStepNavigationClose = () => {
@@ -192,10 +190,6 @@ const handleIntroGuideComplete = () => {
})
}
const handleCompletionGuideComplete = () => {
showCompletionGuide.value = false
}
const handleContinueLearning = () => {
showUnfinishedPrompt.value = false
pendingLeavePath.value = null
@@ -247,6 +241,7 @@ onBeforeRouteLeave((to) => {
<Video
v-if="currentVideo"
:src="currentVideo"
:poster="getVideoPoster(currentVideo)"
v-model="playing"
:muted="false"
:volume="1"
@@ -268,7 +263,6 @@ onBeforeRouteLeave((to) => {
<!-- 步骤导航抽屉 -->
<StepNavigation
:visible="showStepNavigation"
:background-image="getStaticImage('work/vlog背景全屏待定')"
:steps="navigationSteps"
:current-step-index="currentNavigationStep"
:completed-steps="completedNavigationSteps"
@@ -300,16 +294,6 @@ onBeforeRouteLeave((to) => {
准备好了吗?让零散的素材在你的手中拼接成精彩作品!
</GuideStepsItem>
</GuideSteps>
<GuideSteps
:visible="showCompletionGuide"
position-offset-y="85%"
@complete="handleCompletionGuideComplete"
>
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_025.MP3')">
新手导航模块完成啦,快去首页解锁进阶任务吧!
</GuideStepsItem>
</GuideSteps>
</div>
</template>
+84 -29
View File
@@ -5,8 +5,10 @@ import { ImgButton } from '@/components/Button'
import { Overlay } from '@/components/Overlay'
import { ref } from 'vue'
import ImageFilter from '@/components/ImageFilter/ImageFilter.vue'
import { useFilmStore } from '@/stores/modules/film'
const router = useRouter()
const filmStore = useFilmStore()
const selected = ref<number | undefined>(1)
const btnState = ref(false)
@@ -19,27 +21,39 @@ const adjustmentList = ref([
{ name: '对比度', value: 0, min: -100, max: 100 },
{ name: '饱和度', value: 0, min: -100, max: 100 },
{ name: '曝光', value: 0, min: -100, max: 100 },
{ name: '色调', value: 0, min: 0, max: 100 },
{ name: '色调', value: 0, min: -100, max: 100 },
{ name: '锐化', value: 0, min: 0, max: 100 },
{ name: '模糊', value: 0, min: 0, max: 100 }
{ name: '模糊', value: 0, min: 0, max: 100 },
])
const colorBarList = ref([
{ id: 1, name: '原图', filter: 'none' },
{ id: 2, name: '暖阳', filter: 'saturate(2.5) contrast(2.1)' },
{ id: 3, name: '冷调', filter: 'hue-rotate(180deg) contrast(0.9)' },
{
id: 3,
name: '冷调',
filter: 'brightness(0.96) contrast(0.95) saturate(0.95) temperature(-18)',
},
{ id: 4, name: '复古', filter: 'sepia(0.6) contrast(1.2) brightness(0.9)' },
{ id: 5, name: '黑白', filter: 'grayscale(1)' },
{ id: 6, name: '鲜艳', filter: 'sepia(0.1) saturate(2.5) contrast(2.1) brightness(1)' }
{
id: 6,
name: '鲜艳',
filter: 'sepia(0.1) saturate(2.5) contrast(2.1) brightness(1)',
},
])
const recommendedParamsText =
'亮度 90、对比度 100、饱和度 100、曝光 - 100、色调 0、锐化 100、模糊 0'
// 获取指定参数的值
// 获取指定参数的值
const imageFilterRef = ref<InstanceType<typeof ImageFilter> | null>(null)
// 获取指定参数的值
const getVal = (name: string) => {
const val = adjustmentList.value.find(item => item.name === name)?.value || 0
const val =
adjustmentList.value.find((item) => item.name === name)?.value || 0
return Number(val)
}
@@ -57,12 +71,13 @@ const updateFilter = () => {
hue: 0,
blur: 0,
sharpen: 0,
filter: 'none'
filter: 'none',
})
return
}
const selectedFilter = colorBarList.value.find(v => v.id === selected.value)?.filter || 'none'
const selectedFilter =
colorBarList.value.find((v) => v.id === selected.value)?.filter || 'none'
imageFilterRef.value.setOptions({
brightness: getVal('亮度'),
@@ -72,7 +87,7 @@ const updateFilter = () => {
hue: getVal('色调'),
blur: getVal('模糊'),
sharpen: getVal('锐化'),
filter: selectedFilter
filter: selectedFilter,
})
}
@@ -84,13 +99,14 @@ const changeOriginalImgae = () => {
// Tab 切换处理
const handleTabChange = (tab: 'color' | 'filter') => {
activeTab.value = tab
btnState.value = false
// 切换时重置所有参数
handleReset()
}
// 重置所有参数
const handleReset = () => {
adjustmentList.value.forEach(item => (item.value = 0))
adjustmentList.value.forEach((item) => (item.value = 0))
selected.value = 1
updateFilter()
}
@@ -118,8 +134,9 @@ const handleTryOther = () => {
}
const handleFinish = () => {
filmStore.markViewed('FilmColorPractice')
router.push({
name: 'Film'
name: 'Film',
})
}
@@ -146,7 +163,7 @@ const changeColor = (item: { id: number; name: string }) => {
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat -z-10"
:style="{
backgroundImage: getBackgroundImage('after-editing/color/底图')
backgroundImage: getBackgroundImage('after-editing/color/底图'),
}"
></div>
@@ -154,9 +171,14 @@ const changeColor = (item: { id: number; name: string }) => {
<!-- 效果图 -->
<div
class="relative w-[1273px] h-[732px] bg-no-repeat bg-cover mt-10 p-2 pb-[26px] pr-[26px]"
:style="{ backgroundImage: getBackgroundImage('after-editing/color/图片框.png') }"
:style="{
backgroundImage: getBackgroundImage('after-editing/color/图片框.png'),
}"
>
<ImageFilter ref="imageFilterRef" :src="getStaticImage('after-editing/color/film_img')" />
<ImageFilter
ref="imageFilterRef"
:src="getStaticImage('after-editing/color/film_img')"
/>
<!-- 开启对比 -->
<ImgButton
@@ -174,7 +196,9 @@ const changeColor = (item: { id: number; name: string }) => {
<div>
<div
class="w-[445px] h-[759px] bg-no-repeat bg-cover p-2 rounded-xl"
:style="{ backgroundImage: getBackgroundImage('after-editing/color/小底板') }"
:style="{
backgroundImage: getBackgroundImage('after-editing/color/小底板'),
}"
>
<!-- tab切换 -->
<header class="flex h-[75px] items-center justify-center space-x-4">
@@ -204,8 +228,14 @@ const changeColor = (item: { id: number; name: string }) => {
v-show="activeTab === 'color'"
class="space-y-6 h-full overflow-y-auto custom-scrollbar pt-4"
>
<div v-for="item in adjustmentList" :key="item.name" class="px-2">
<div class="flex justify-between text-[#648ab2] text-2xl font-bold mb-2">
<div
v-for="item in adjustmentList"
:key="item.name"
class="px-2"
>
<div
class="flex justify-between text-[#648ab2] text-2xl font-bold mb-2"
>
<span>{{ item.name }}</span>
<span>{{ item.value }}</span>
</div>
@@ -232,11 +262,11 @@ const changeColor = (item: { id: number; name: string }) => {
backgroundImage:
selected == item.id
? getBackgroundImage('after-editing/color/滤镜-选中')
: getBackgroundImage('after-editing/color/滤镜-未选中')
: getBackgroundImage('after-editing/color/滤镜-未选中'),
}"
:class="[
'mx-auto w-[373px] h-[62px] text-white text-2xl font-bold flex items-center justify-center cursor-pointer bg-no-repeat bg-contain bg-center transition-all hover:scale-105',
selected !== item.id && 'text-gray-700'
selected !== item.id && 'text-gray-700',
]"
@click="changeColor(item)"
>
@@ -247,38 +277,63 @@ const changeColor = (item: { id: number; name: string }) => {
</div>
<div class="flex justify-around mt-4">
<!-- 重置 -->
<ImgButton image="after-editing/color/重置" @click="handleReset" />
<ImgButton
image="after-editing/color/重置"
@click="handleReset"
/>
<!-- 生成 -->
<ImgButton image="after-editing/color/生成" @click="handleGenerate" />
<ImgButton
image="after-editing/color/生成"
@click="handleGenerate"
/>
</div>
</div>
</div>
<!-- title -->
<img class="absolute top-0 left-0" :src="getStaticImage('after-editing/color/标题-调色实践')" />
<img
class="absolute top-0 left-0"
:src="getStaticImage('after-editing/color/标题-调色实践')"
/>
<!-- 回到顶部按钮 - 最后一页显示 -->
<p class="absolute left-40 bottom-10 flex items-center">
<img class="rotate-0 w-28 mr-4" src="@/assets/svg/deng.svg" />
<img
class="rotate-0 w-28 mr-4"
src="@/assets/svg/deng.svg"
/>
<span class="text-white text-2xl font-bold">
推荐参数:亮度 90、对比度 100、饱和度 100、曝光 - 90、色调 0、锐化 100、模糊 0
推荐参数:{{ recommendedParamsText }}
</span>
</p>
<!-- 保存图片弹窗 -->
<Overlay :visible="showModal" @close="handleModalClose">
<div class="relative w-[1000px] h-[600px] flex items-center justify-center">
<Overlay
:visible="showModal"
@close="handleModalClose"
>
<div
class="relative w-[1000px] h-[600px] flex items-center justify-center"
>
<!-- 截图内容 -->
<div class="bg-white p-2 rounded-md -rotate-6">
<img :src="screenshot" alt="调色结果" />
<img
:src="screenshot"
alt="调色结果"
/>
</div>
<!-- <img :src="screenshot" alt="调色结果" class="max-w-full max-h-full object-contain rotate-2" /> -->
<!-- 操作按钮 -->
<div class="absolute bottom-1/2 -left-1/3 translate-y-1/2">
<ImgButton image="after-editing/color/按钮-试试其他调色" @click="handleTryOther" />
<ImgButton
image="after-editing/color/按钮-试试其他调色"
@click="handleTryOther"
/>
</div>
<div class="absolute bottom-1/2 -right-1/3 translate-y-1/2">
<ImgButton image="after-editing/color/按钮-结束调色实践" @click="handleFinish" />
<ImgButton
image="after-editing/color/按钮-结束调色实践"
@click="handleFinish"
/>
</div>
</div>
</Overlay>
+30 -5
View File
@@ -1,11 +1,17 @@
<script lang="ts" setup>
import { IMAGE_PATHS, getStaticImage } from '@/utils'
import { IMAGE_PATHS, getStaticImage, getVideoPoster } from '@/utils'
import { ImgButton } from '@/components/Button'
import { useRouter } from 'vue-router'
import { Video } from '@/components'
import { getPublicPath } from '@/utils'
import { useFilmStore } from '@/stores/modules/film'
const router = useRouter()
const filmStore = useFilmStore()
const handleVideoEnded = () => {
filmStore.markViewed('FilmColor')
}
const back = () => {
router.back()
@@ -20,22 +26,41 @@ const back = () => {
@click="back"
/>
<!-- 背景 -->
<img class="absolute inset-0" :src="getStaticImage('img/背景.png')" alt="" />
<img
class="absolute inset-0"
:src="getStaticImage('img/背景.png')"
alt=""
/>
<!-- 标题 -->
<img class="absolute top-5 z-10" :src="getStaticImage('img/调色-标题.png')" alt="" />
<img
class="absolute top-5 z-10"
:src="getStaticImage('img/调色-标题.png')"
alt=""
/>
<!-- 内容 -->
<div class="relative h-full pt-28 overflow-y-scroll">
<img :src="getStaticImage('film/img/调色.png')" alt="" />
<img
:src="getStaticImage('film/img/调色.png')"
alt=""
/>
<div class="absolute top-[6250px] left-36 w-[1670px] h-[1000px] rounded-2xl overflow-hidden">
<div
class="absolute top-[6250px] left-36 w-[1670px] h-[1000px] rounded-2xl overflow-hidden"
>
<Video
:src="getPublicPath('video/film/editing/color_jianji_batch.mp4')"
:poster="
getVideoPoster(
getPublicPath('video/film/editing/color_jianji_batch.mp4'),
)
"
playsinline
controls
:muted="false"
:volume="1"
@ended="handleVideoEnded"
/>
</div>
@@ -3,7 +3,7 @@ import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { Video } from '@/components'
import { getPublicPath, IMAGE_PATHS } from '@/utils'
import { getPublicPath, IMAGE_PATHS, getVideoPoster } from '@/utils'
import { useFilmStore } from '@/stores'
const router = useRouter()
@@ -14,7 +14,9 @@ const handleBack = () => {
}
// 关联剪辑视频 URL(暂时用其他视频代替)
const associationVideoUrl = getPublicPath('video/film/editing/dygmk_gljj_batch.mp4')
const associationVideoUrl = getPublicPath(
'video/film/editing/dygmk_gljj_batch.mp4',
)
// 视频播放状态
const videoPlaying = ref(false)
@@ -30,6 +32,7 @@ const handleVideoEnded = () => {
<Video
class="w-full h-full"
:src="associationVideoUrl"
:poster="getVideoPoster(associationVideoUrl)"
v-model="videoPlaying"
:muted="false"
:volume="1"
@@ -1,9 +1,8 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { Video } from '@/components'
import { getPublicPath, IMAGE_PATHS } from '@/utils'
import { getPublicPath, IMAGE_PATHS, getVideoPoster } from '@/utils'
import { useFilmStore } from '@/stores'
const router = useRouter()
@@ -14,17 +13,12 @@ const handleBack = () => {
}
// 连续剪辑视频 URL(暂时用其他视频代替)
const continuityVideoUrl = getPublicPath('video/film/editing/dygmk_lxjj_batch.mp4')
// 视频播放状态
const videoEnded = ref(false)
const videoPlaying = ref(false)
const showSummary = ref(false)
const continuityVideoUrl = getPublicPath(
'video/film/editing/dygmk_lxjj_batch.mp4',
)
// 视频播放完成
const handleVideoEnded = () => {
videoEnded.value = true
showSummary.value = true
film.markEditingVideoWatched('continuity')
}
</script>
@@ -35,7 +29,7 @@ const handleVideoEnded = () => {
<Video
class="w-full h-full"
:src="continuityVideoUrl"
v-model="videoPlaying"
:poster="getVideoPoster(continuityVideoUrl)"
:muted="false"
:volume="1"
:controls="true"
+2 -1
View File
@@ -3,7 +3,7 @@ import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { Video } from '@/components'
import { getPublicPath, IMAGE_PATHS } from '@/utils'
import { getPublicPath, IMAGE_PATHS, getVideoPoster } from '@/utils'
import { useFilmStore } from '@/stores'
const router = useRouter()
@@ -30,6 +30,7 @@ const handleVideoEnded = () => {
<Video
class="w-full h-full"
:src="cutVideoUrl"
:poster="getVideoPoster(cutVideoUrl)"
v-model="videoPlaying"
:muted="false"
:volume="1"
+87 -20
View File
@@ -14,6 +14,13 @@ import { useFilmStore } from '@/stores'
const router = useRouter()
const film = useFilmStore()
const isEditingUnlocked = computed(
() => film.editingVideosWatched.start === true,
)
const isEditingItemLocked = (videoKey: string) =>
videoKey !== 'start' && !isEditingUnlocked.value
const completionIcon = (completed: boolean) =>
getStaticImage(`midterm/${completed ? '完成' : '未完成'}.png`)
@@ -22,40 +29,46 @@ const editingItems = computed(() => [
image: 'film/after-editing/剪辑初始',
videoKey: 'start',
onClick: goStart,
iconClass: 'right-14 top-9',
class:
'absolute left-[0px] bottom-[42%] translate-y-1/2 bg-cover bg-center bg-no-repeat',
},
{
image: 'film/after-editing/节奏型剪辑',
image: 'film/after-editing/img/节奏型剪辑',
textImage: 'film/after-editing/text/节奏型剪辑',
videoKey: 'rhythm',
onClick: goRhythm,
class: 'absolute right-[0px] top-[160px] bg-cover bg-center bg-no-repeat',
class: 'absolute right-[215px] top-[170px] bg-cover bg-center bg-no-repeat',
},
{
image: 'film/after-editing/声音转场',
image: 'film/after-editing/img/声音转场',
textImage: 'film/after-editing/text/声音转场',
videoKey: 'soundTransition',
onClick: goSoundTransition,
class: 'absolute right-[42px] top-[410px] bg-cover bg-center bg-no-repeat',
class: 'absolute right-[215px] top-[420px] bg-cover bg-center bg-no-repeat',
},
{
image: 'film/after-editing/快切',
image: 'film/after-editing/img/快切',
textImage: 'film/after-editing/text/快切',
videoKey: 'cut',
onClick: goCut,
class: 'absolute right-[520px] top-[580px] bg-cover bg-center bg-no-repeat',
class: 'absolute right-[620px] top-[560px] bg-cover bg-center bg-no-repeat',
},
{
image: 'film/after-editing/关联剪辑',
image: 'film/after-editing/img/关联剪辑',
textImage: 'film/after-editing/text/关联剪辑',
videoKey: 'association',
onClick: goAssociation,
class:
'absolute right-[42px] bottom-[270px] bg-cover bg-center bg-no-repeat',
'absolute right-[215px] bottom-[290px] bg-cover bg-center bg-no-repeat',
},
{
image: 'film/after-editing/连续剪辑',
image: 'film/after-editing/img/连续剪辑',
textImage: 'film/after-editing/text/连续剪辑',
videoKey: 'continuity',
onClick: goContinuity,
class:
'absolute right-[42px] bottom-[16px] bg-cover bg-center bg-no-repeat',
'absolute right-[215px] bottom-[35px] bg-cover bg-center bg-no-repeat',
},
])
@@ -87,6 +100,11 @@ const goContinuity = () => {
router.push({ name: 'FilmEditingContinuity' })
}
const goEditingItem = (item: (typeof editingItems.value)[number]) => {
if (isEditingItemLocked(item.videoKey)) return
item.onClick()
}
// 6 个剪辑子视频学习完成后的一次性引导
const editingCompleteSteps: GuideStep[] = [
{
@@ -121,7 +139,11 @@ const handleEditingCompleteGuideDone = () => {
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
:style="{
backgroundImage: getBackgroundImage('film/after-editing/剪辑底图'),
backgroundImage: getBackgroundImage(
isEditingUnlocked
? 'film/after-editing/剪辑底图亮'
: 'film/after-editing/剪辑底图灰',
),
}"
></div>
@@ -137,15 +159,45 @@ const handleEditingCompleteGuideDone = () => {
:key="item.videoKey"
:class="item.class"
>
<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
:class="[
'relative transition-all duration-200',
isEditingItemLocked(item.videoKey)
? 'opacity-40 pointer-events-none'
: 'hover:scale-105 hover:cursor-pointer hover:brightness-125',
]"
@click="goEditingItem(item)"
>
<!-- img -->
<img :src="getStaticImage(item.image)" />
<!-- 点击提示 -->
<img
v-if="item.videoKey === 'start' && !isEditingUnlocked"
class="pointer-events-none absolute right-6 -bottom-40 z-20 start-click-hint"
:src="getStaticImage('film/after-editing/点击此处开始')"
alt=""
/>
<!-- 文本 -->
<img
class="absolute right-0 bottom-6 translate-x-[64%] translate-y-1/2 z-10"
:src="getStaticImage(item.textImage ?? '')"
alt=""
/>
<!-- 完成图标 -->
<img
:class="[
'pointer-events-none absolute z-10 w-16',
item.iconClass ?? '-right-6 -top-6',
]"
:src="
completionIcon(film.editingVideosWatched[item.videoKey] === true)
"
alt=""
/>
</div>
</div>
<!-- 关闭 -->
@@ -167,6 +219,21 @@ const handleEditingCompleteGuideDone = () => {
</template>
<style scoped>
.start-click-hint {
animation: start-click-hint 1.2s ease-in-out infinite;
}
@keyframes start-click-hint {
0%,
100% {
transform: translateY(0) scale(1);
}
50% {
transform: translateY(-14px) scale(1.05);
}
}
.onsite-btn {
@apply relative flex flex-col items-start backdrop-blur-md rounded-2xl px-10 py-8 text-left bg-white/5 hover:bg-white/10 border border-white/10 hover:border-white/30 transition-all duration-200 cursor-pointer;
}
@@ -3,7 +3,7 @@ import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { Video } from '@/components'
import { getPublicPath, IMAGE_PATHS } from '@/utils'
import { getPublicPath, IMAGE_PATHS, getVideoPoster } from '@/utils'
import { useFilmStore } from '@/stores'
const router = useRouter()
@@ -30,6 +30,7 @@ const handleVideoEnded = () => {
<Video
class="w-full h-full"
:src="rhythmVideoUrl"
:poster="getVideoPoster(rhythmVideoUrl)"
v-model="videoPlaying"
:muted="false"
:volume="1"
@@ -6,7 +6,12 @@ import { Video } from '@/components'
import QuestionQuiz, {
type Question,
} from '@/components/QuestionQuiz/QuestionQuiz.vue'
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
import {
getBackgroundImage,
getPublicPath,
IMAGE_PATHS,
getVideoPoster,
} from '@/utils'
import { useFilmStore } from '@/stores'
const router = useRouter()
@@ -50,6 +55,7 @@ const questions = ref<Question[]>([
],
correctOptionId: '', // 引导题,无对错
mediaSrc: soundTransitionVideoUrl,
explanation: '声音不只能被“听见”,还会影响观众对画面节奏与内容变化的感受。',
},
{
id: 2,
@@ -62,6 +68,8 @@ const questions = ref<Question[]>([
],
correctOptionId: 'b',
mediaSrc: 'film/on-site/声音转场jh_2.png',
explanation:
'相似声音可以在两个画面之间建立听觉关联,让观众自然接受场景转换;直接切画面容易显得突兀,字幕说明也不能替代声音本身的过渡作用。',
},
])
@@ -80,7 +88,9 @@ const handleQuestionsComplete = () => {
<!-- 背景 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat -z-10"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.SHOOTING.BG) }"
:style="{
backgroundImage: getBackgroundImage('film/after-editing/拍摄底图'),
}"
></div>
<!-- 视频播放区域 -->
@@ -91,6 +101,7 @@ const handleQuestionsComplete = () => {
<Video
class="w-full h-full"
:src="soundTransitionVideoUrl"
:poster="getVideoPoster(soundTransitionVideoUrl)"
v-model="videoPlaying"
:muted="false"
:volume="1"
@@ -125,6 +136,7 @@ const handleQuestionsComplete = () => {
<Video
class="w-full h-full"
:src="soundTransitionVideoUrl3"
:poster="getVideoPoster(soundTransitionVideoUrl3)"
playsinline
controls
:muted="false"
@@ -146,7 +158,7 @@ const handleQuestionsComplete = () => {
<!-- 返回按钮 -->
<ImgButton
class="absolute right-0 top-0 z-20"
class="absolute right-0 top-0 z-30"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleBack"
/>
+25 -24
View File
@@ -3,7 +3,12 @@ import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { Video } from '@/components'
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
import {
getBackgroundImage,
getPublicPath,
IMAGE_PATHS,
getVideoPoster,
} from '@/utils'
import { useFilmStore } from '@/stores'
const router = useRouter()
@@ -14,7 +19,9 @@ const handleBack = () => {
}
// 连续剪辑视频 URL(暂时用其他视频代替)
const continuityVideoUrl = getPublicPath('video/on-site/dygmk_jjcnlks_batch.mp4')
const continuityVideoUrl = getPublicPath(
'video/on-site/dygmk_jjcnlks_batch.mp4',
)
// 视频播放状态
const videoEnded = ref(false)
@@ -28,28 +35,22 @@ const handleVideoEnded = () => {
</script>
<template>
<div class="relative w-full h-full flex items-center justify-center overflow-hidden">
<!-- 背景 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat -z-10"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.SHOOTING.BG) }"
></div>
<!-- 视频播放区域 -->
<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="continuityVideoUrl"
v-model="videoPlaying"
:muted="false"
:volume="1"
:controls="true"
:hideNativeControls="true"
:autoplay="true"
:show-center-play-icon="true"
@ended="handleVideoEnded"
></Video>
</div>
<div
class="relative w-full h-full flex items-center justify-center overflow-hidden"
>
<Video
class="w-full h-full"
:src="continuityVideoUrl"
:poster="getVideoPoster(continuityVideoUrl)"
v-model="videoPlaying"
:muted="false"
:volume="1"
:controls="true"
:hideNativeControls="true"
:autoplay="true"
:show-center-play-icon="true"
@ended="handleVideoEnded"
></Video>
<!-- 返回按钮 -->
<ImgButton
@@ -3,7 +3,7 @@ import { nextTick, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { Video, GuideSteps, GuideStepsItem } from '@/components'
import { getPublicPath, IMAGE_PATHS } from '@/utils'
import { getPublicPath, IMAGE_PATHS, getVideoPoster } from '@/utils'
import { useFilmStore } from '@/stores/modules/film'
const router = useRouter()
@@ -32,6 +32,10 @@ const handleComplete = () => {
videoPlaying.value = true
})
}
const handleVideoEnded = () => {
filmStore.markViewed('FilmExportPublish')
}
</script>
<template>
@@ -40,11 +44,13 @@ const handleComplete = () => {
<Video
class="w-full h-full"
:src="randomVideoUrl"
:poster="getVideoPoster(randomVideoUrl)"
v-model="videoPlaying"
:muted="false"
:volume="1"
:controls="true"
:hideNativeControls="true"
@ended="handleVideoEnded"
/>
<!-- 返回按钮 -->
@@ -1,10 +1,27 @@
<script lang="ts" setup>
import { IMAGE_PATHS, getPublicPath, getStaticImage } from '@/utils'
import {
IMAGE_PATHS,
getPublicPath,
getStaticImage,
getVideoPoster,
} from '@/utils'
import { ImgButton } from '@/components/Button'
import { Video } from '@/components'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const videoPlayingStates = ref([false, false, false])
const playOnly = (index: number) => {
videoPlayingStates.value = videoPlayingStates.value.map(
(_, currentIndex) => currentIndex === index,
)
}
const updatePlayingState = (index: number, playing: boolean) => {
videoPlayingStates.value[index] = playing
}
const back = () => {
// 返回上一页
@@ -20,42 +37,73 @@ const back = () => {
@click="back"
/>
<!-- 背景 -->
<img class="absolute inset-0" :src="getStaticImage('img/背景.png')" alt="" />
<img
class="absolute inset-0"
:src="getStaticImage('img/背景.png')"
alt=""
/>
<!-- 标题 -->
<img class="absolute top-5 z-10" :src="getStaticImage('img/音效-标题.png')" alt="" />
<img
class="absolute top-5 z-10"
:src="getStaticImage('img/音效-标题.png')"
alt=""
/>
<!-- 内容 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] -->
<div class="relative h-full pt-28 overflow-y-scroll">
<img :src="getStaticImage('film/img/音效.png')" alt="" />
<img
:src="getStaticImage('film/img/音效.png')"
alt=""
/>
<div class="absolute top-[3850px] left-64 w-[1450px] h-[1000px] rounded-2xl overflow-hidden">
<div
class="absolute top-[3850px] left-64 w-[1450px] h-[1000px] rounded-2xl overflow-hidden"
>
<Video
v-model="videoPlayingStates[0]"
:src="getPublicPath('video/editing/yingxiao_batch.mp4')"
:poster="
getVideoPoster(getPublicPath('video/editing/yingxiao_batch.mp4'))
"
playsinline
controls
:volume="1"
:muted="false"
@play="playOnly(0)"
@update:model-value="updatePlayingState(0, $event)"
/>
</div>
<div class="absolute top-[480px] left-[12%] w-[840px]">
<Video
v-model="videoPlayingStates[1]"
:src="getPublicPath('video/editing/yingxiao_wu.mp4')"
:poster="
getVideoPoster(getPublicPath('video/editing/yingxiao_wu.mp4'))
"
playsinline
controls
:volume="1"
:muted="false"
@play="playOnly(1)"
@update:model-value="updatePlayingState(1, $event)"
/>
</div>
<div class="absolute top-[1900px] left-[18.5%] w-[1250px]">
<Video
v-model="videoPlayingStates[2]"
:src="getPublicPath('video/editing/yingxiao_you.mp4')"
:poster="
getVideoPoster(getPublicPath('video/editing/yingxiao_you.mp4'))
"
playsinline
controls
:volume="1"
:muted="false"
@play="playOnly(2)"
@update:model-value="updatePlayingState(2, $event)"
/>
</div>
</div>
+11 -11
View File
@@ -59,6 +59,14 @@ const returnSteps: GuideStep[] = [
content: '这里既保留着工业时代的痕迹,也承载着当代生活的节奏。',
audioSrc: getPublicPath('audio/py_4_011.MP3'),
},
{
id: 'return-step3',
content:
'制作变焦镜分为【前期准备】【中期拍摄】【后期剪辑】三个阶段,我们依次来学习!',
audioSrc: getPublicPath('audio/py_2_008.MP3'),
positionOffsetY: '50%',
highlightSelector: '.steps-section',
},
]
const handleReturnGuideComplete = () => {
@@ -178,16 +186,6 @@ watch(
},
{ immediate: true },
)
watch(
showPracticeGuide,
(val) => {
if (val) {
film.markGuideShown(practiceGuideKey)
}
},
{ immediate: true },
)
</script>
<template>
@@ -252,7 +250,9 @@ watch(
position-offset-y="50%"
@complete="film.markGuideShown(practiceGuideKey)"
>
<GuideStepsItem>点击这里进入实战操作台</GuideStepsItem>
<GuideStepsItem audio-src="audio/py_6_001.MP3">
点击进入实战操作台,开启你的创作实践!
</GuideStepsItem>
</GuideSteps>
<!-- 引导任务 -->
@@ -9,8 +9,14 @@ import { GuideSteps } from '@/components/Guide'
import { onBeforeUnmount, ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { useFilmStore } from '@/stores'
//最后一个设备看到的停留时间
const lastDeviceStayTime = 5000
const router = useRouter()
const filmStore = useFilmStore()
const equipmentGuideKey = 'film-midterm-equipment-finish'
const back = () => {
// 跳转设备准备
@@ -25,12 +31,13 @@ const steps = ref([
])
const showGuide = ref(false)
const guideTriggered = ref(false)
const guideTriggered = ref(filmStore.guideShown[equipmentGuideKey] === true)
const guideTimer = ref<ReturnType<typeof setTimeout> | null>(null)
const viewedMenuKeys = ref<Set<string>>(new Set())
const hiddenbar = ref(false)
const currentIndex = ref(0) // 当前显示的图片索引
const selectedMenuIndex = ref(0) // 当前选中的菜单项索引
const contentScrollRef = ref<HTMLElement | null>(null)
type EquipmentCategoryId =
| 'camera'
@@ -139,7 +146,12 @@ const maybeTriggerGuide = () => {
guideTimer.value = setTimeout(() => {
showGuide.value = true
guideTimer.value = null
}, 3000)
}, lastDeviceStayTime)
}
const handleEquipmentGuideComplete = () => {
showGuide.value = false
filmStore.markGuideShown(equipmentGuideKey)
}
onBeforeUnmount(() => {
@@ -171,6 +183,7 @@ const selectMenuItem = (
}
selectedMenuIndex.value = index
currentIndex.value = 0
contentScrollRef.value?.scrollTo({ top: 0 })
viewedMenuKeys.value.add(item.id)
maybeTriggerGuide()
}
@@ -301,7 +314,10 @@ const prevPhone = () => {
alt=""
/>
</div>
<div class="plate-content">
<div
ref="contentScrollRef"
class="plate-content"
>
<div class="text">
<img
:src="currentContentImg"
@@ -331,6 +347,7 @@ const prevPhone = () => {
:highlight-padding="12"
:highlight-radius="20"
position-offset-y="85%"
@close="handleEquipmentGuideComplete"
/>
</div>
</template>
@@ -1,140 +0,0 @@
<script lang="tsx" setup>
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button'
import { ref } from 'vue'
import { ProgressBar } from '@/components'
import { useRouter } from 'vue-router'
const router = useRouter()
const currentStep = ref(2)
const handleComplete = () => {
console.log('完成前置条件')
// 处理完成逻辑
}
const handleStepChange = (step: number) => {
console.log('步骤变化:', step)
// 处理步骤变化逻辑
}
const handleWorkClick = () => {
console.log('任务取景按钮点击')
// 处理任务取景按钮点击逻辑
router.push({ name: 'Work' })
}
const handleClick = () => {
// 跳转设备准备
router.push({ name: 'equipment' })
}
const shooting = () => {
// 跳转实地取景
router.push({ name: 'shooting' })
}
const highlightShow = ref(true)
</script>
<template>
<div class="w-full h-full relative">
<!-- 背景图片-center -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.BG_CENTER) }"
></div>
<!-- 背景图片 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.BG) }"
></div>
<!-- 标题 -->
<div class="absolute top-2 left-1/2 -translate-x-1/2">
<ImgButton :image="IMAGE_PATHS.VLOG.TITLE" />
</div>
<!-- 任务取景按钮 -->
<div
class="absolute left-24 top-24"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.TASK_CAMERA) }"
>
<ImgButton :image="IMAGE_PATHS.VLOG.TASK_BTN" @click="handleWorkClick" />
</div>
<!-- 中期拍摄底板 -->
<div v-if="highlightShow" class="absolute right-90 ml-[135px]" style="z-index: 998">
<img :src="getStaticImage(IMAGE_PATHS.VLOG.Midterm)" />
</div>
<!-- 进度百分比 -->
<div
v-if="highlightShow"
class="absolute left-1/2 top-[12%] -translate-x-1/2 pointer-events-none"
>
<div class="flex">
<ImgButton :image="IMAGE_PATHS.VLOG.Progress" />
<ImgButton :image="IMAGE_PATHS.VLOG.Four" />
<ImgButton :image="IMAGE_PATHS.VLOG.Zero" />
<ImgButton :image="IMAGE_PATHS.VLOG.Percentage" />
</div>
</div>
<!-- 中期准备按钮文字 -->
<div
v-if="highlightShow"
class="absolute left-[21%] top-[9%] -translate-x-1/2"
style="z-index: 998"
>
<ImgButton
:image="IMAGE_PATHS.VLOG.MidtermEquipment"
@click="handleClick"
class="ml-[60px]"
/>
<ImgButton :image="IMAGE_PATHS.VLOG.MidtermFraming" @click="shooting" class="ml-[40px]" />
<ImgButton :image="IMAGE_PATHS.VLOG.MidtermShoot" class="ml-[20px]" />
</div>
<!-- 遮罩层 -->
<div class="Shadow" v-if="highlightShow">
<div>
<img src="" alt="" />
</div>
<img src="@/assets/images/midterm/引导遮罩.png" alt="" />
</div>
<!-- 叉 -->
<div
v-if="highlightShow"
class="absolute right-0 top-0"
style="z-index: 998; cursor: pointer"
@click="highlightShow = false"
>
<img src="@/assets/images/midterm/叉.png" alt="" />
</div>
<!-- 引导任务 -->
<!-- <ImgButton class="absolute right-24 bottom-24" :image="IMAGE_PATHS.VLOG.TASK_PERSON" /> -->
<div class="absolute bottom-0 w-full bright">
<ProgressBar
:current-step="currentStep"
@complete="handleComplete"
@step-change="handleStepChange"
/>
</div>
</div>
</template>
<style lang="less" scopeds>
.Shadow {
width: 100%;
height: 100%;
img {
width: 100%;
height: 100%;
z-index: 997 !important;
position: absolute;
}
}
.bright {
z-index: 999 !important;
}
</style>
@@ -47,6 +47,8 @@ const questions = ref<Question[]>([
],
correctOptionId: 'b',
mediaSrc: getPublicPath('video/film/on-site/xianchangpaishe_dijiaodu.mp4'),
explanation:
'低角度仰拍会让人物动作更有向上伸展和力量感,适合突出人物状态与气势;它并不是为了真实放大身高、增加背景或堆叠更多信息。',
},
])
@@ -64,7 +66,7 @@ const handleQuestionsComplete = () => {
<!-- 背景 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.SHOOTING.BG) }"
:style="{ backgroundImage: getBackgroundImage('film/on-site/现场拍摄') }"
></div>
<!-- 交互一 & 交互二:选择题 -->
@@ -3,7 +3,7 @@ import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { Video } from '@/components'
import { getPublicPath, IMAGE_PATHS } from '@/utils'
import { getPublicPath, IMAGE_PATHS, getVideoPoster } from '@/utils'
import { useFilmStore } from '@/stores/modules/film'
const router = useRouter()
@@ -33,6 +33,7 @@ const handleVideoEnded = () => {
<Video
class="w-full h-full"
:src="randomVideoUrl"
:poster="getVideoPoster(randomVideoUrl)"
v-model="videoPlaying"
:muted="false"
:volume="1"
@@ -6,7 +6,7 @@ import { Video } from '@/components'
import QuestionQuiz, {
type Question,
} from '@/components/QuestionQuiz/QuestionQuiz.vue'
import { getPublicPath, IMAGE_PATHS } from '@/utils'
import { getPublicPath, IMAGE_PATHS, getVideoPoster } from '@/utils'
import { useFilmStore } from '@/stores/modules/film'
const router = useRouter()
@@ -68,6 +68,7 @@ const handleQuestionsComplete = () => {
<Video
class="w-full h-full"
:src="sliderVideoUrl"
:poster="getVideoPoster(sliderVideoUrl)"
v-model="videoPlaying"
:muted="false"
:volume="1"
@@ -49,6 +49,8 @@ const questions = ref<Question[]>([
mediaSrc: getPublicPath(
'video/film/on-site/xianchangpaishe_baitianmanmen.mp4',
),
explanation:
'白天环境光较强时,需要通过 ND 镜片减少进光量,才能降低快门速度并拉出运动轨迹。提高 ISO 会让画面更亮,连拍模式也不能形成慢门拖影效果。',
},
])
@@ -66,7 +68,7 @@ const handleQuestionsComplete = () => {
<!-- 背景 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.SHOOTING.BG) }"
:style="{ backgroundImage: getBackgroundImage('film/on-site/现场拍摄') }"
></div>
<!-- 交互一 & 交互二:选择题 -->
@@ -115,7 +117,7 @@ const handleQuestionsComplete = () => {
<!-- 返回按钮 -->
<ImgButton
class="absolute right-0 top-0 z-20"
class="absolute right-0 top-0 z-30"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleBack"
/>
@@ -3,7 +3,12 @@ import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { Video } from '@/components'
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
import {
getBackgroundImage,
getPublicPath,
IMAGE_PATHS,
getVideoPoster,
} from '@/utils'
import { useFilmStore } from '@/stores/modules/film'
const router = useRouter()
@@ -31,7 +36,7 @@ const handleVideoEnded = () => {
<!-- 背景 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat -z-10"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.SHOOTING.BG) }"
:style="{ backgroundImage: getBackgroundImage('film/on-site/现场拍摄') }"
></div>
<!-- 视频播放区域 -->
@@ -39,6 +44,7 @@ const handleVideoEnded = () => {
<Video
class="w-full h-full"
:src="slowShutterNightVideoUrl"
:poster="getVideoPoster(slowShutterNightVideoUrl)"
v-model="videoPlaying"
:muted="false"
:volume="1"
@@ -6,7 +6,12 @@ import { Video } from '@/components'
import QuestionQuiz, {
type Question,
} from '@/components/QuestionQuiz/QuestionQuiz.vue'
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
import {
getBackgroundImage,
getPublicPath,
IMAGE_PATHS,
getVideoPoster,
} from '@/utils'
import { useFilmStore } from '@/stores/modules/film'
const router = useRouter()
@@ -36,6 +41,8 @@ const questions = ref<Question[]>([
],
correctOptionId: 'c',
mediaSrc: 'film/on-site/延时拍摄jh_1.png', // 占位:请按需替换为真实图片
explanation:
'有些变化虽然一直存在,但在正常速度下,并不容易被观众明显感知。延时拍摄适合把时间中的连续变化压缩呈现,让时间流动变得更清楚。',
},
{
id: 2,
@@ -50,6 +57,8 @@ const questions = ref<Question[]>([
],
correctOptionId: 'c',
mediaSrc: 'film/on-site/延时拍摄jh_1.png', // 占位:请按需替换为真实图片
explanation:
'延时拍摄能够压缩时间,让原本缓慢发生的变化在短时间内被清晰看到;手持、慢动作或推拉镜头都不能直接强化时间流逝感。',
},
])
@@ -68,7 +77,7 @@ const handleQuestionsComplete = () => {
<!-- 背景 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.SHOOTING.BG) }"
:style="{ backgroundImage: getBackgroundImage('film/on-site/现场拍摄') }"
></div>
<!-- 交互一 & 交互二:选择题 -->
@@ -127,6 +136,7 @@ const handleQuestionsComplete = () => {
<Video
class="w-full h-full"
:src="timeLapseVideoUrl"
:poster="getVideoPoster(timeLapseVideoUrl)"
v-model="videoPlaying"
:muted="false"
:volume="1"
@@ -4,17 +4,22 @@ import { getBackgroundImage } from '@/utils'
import { ImgButton } from '@/components/Button'
import { Overlay } from '@/components/Overlay'
import { useRouter } from 'vue-router'
import { useFilmStore } from '@/stores/modules/film'
import { useViewfinderA } from './useViewfinderA'
import { useCanvasCapture } from '../composables/useCanvasCapture'
import StartOverlay from '../components/StartOverlay.vue'
import LoadingOverlay from '@/components/Loading/LoadingOverlay.vue'
const router = useRouter()
const filmStore = useFilmStore()
const start = ref(false)
// 基础逻辑
const back = () => router.back()
const handleFinish = () => router.push({ name: 'FilmFilmingLocationB' })
const handleFinish = () => {
filmStore.markViewed('FilmFilmingLocationA')
router.push({ name: 'FilmFilmingLocationB' })
}
// 截图功能
const { isLoading, showModal, capturedImageUrl, capture } = useCanvasCapture()
@@ -35,7 +40,7 @@ const {
getTransform,
currentOffsetX,
currentOffsetY,
handleWheel
handleWheel,
} = useViewfinderA()
// 刻度盘配置常量
@@ -88,7 +93,7 @@ const dialTicks = computed<DialTick[]>(() => {
angle,
isMajor,
isInteger,
label: isInteger ? zoom + 'x' : ''
label: isInteger ? zoom + 'x' : '',
})
}
return ticks
@@ -116,7 +121,7 @@ const capturePhoto = async () => {
if (isLoading.value) return
isCapturing.value = true
await new Promise(resolve => setTimeout(resolve, FLASH_DURATION))
await new Promise((resolve) => setTimeout(resolve, FLASH_DURATION))
await capture({
width: CANVAS_WIDTH,
@@ -124,7 +129,7 @@ const capturePhoto = async () => {
scale: CANVAS_SCALE,
delay: UI_UPDATE_DELAY,
resultDelay: RESULT_DISPLAY_DELAY,
onDraw: ctx => {
onDraw: (ctx) => {
if (!photoImage.value) {
throw new Error('Photo image not found')
}
@@ -142,12 +147,12 @@ const capturePhoto = async () => {
-CANVAS_WIDTH / 2,
-CANVAS_HEIGHT / 2,
CANVAS_WIDTH,
CANVAS_HEIGHT
CANVAS_HEIGHT,
)
},
onComplete: () => {
isCapturing.value = false
}
},
})
}
</script>
@@ -156,10 +161,21 @@ const capturePhoto = async () => {
<div class="w-full h-full">
<div
class="relative w-full h-full bg-cover bg-center bg-no-repeat shooting-bg"
:style="{ backgroundImage: getBackgroundImage('film/shooting/龙美术馆模糊底图.png') }"
:style="{
backgroundImage: getBackgroundImage(
'film/shooting/龙美术馆模糊底图.png',
),
}"
>
<ImgButton image="film/shooting/标题-龙美术馆.png" class="title-button absolute left-0" />
<ImgButton image="shooting/返回.png" class="back-button absolute right-0" @click="back" />
<ImgButton
image="film/shooting/标题-龙美术馆.png"
class="title-button absolute left-0"
/>
<ImgButton
image="shooting/返回.png"
class="back-button absolute right-0"
@click="back"
/>
<p
class="absolute left-1/2 -translate-x-1/2 top-[260px] text-red-500 text-4xl leading-normal font-bold"
@@ -167,10 +183,17 @@ const capturePhoto = async () => {
操作说明:在手机取景框区域,滚动鼠标滚轮可缩放画面,按住鼠标左键拖动可调整画面位置。
</p>
<StartOverlay scene="a" v-if="!start" @start="start = true" />
<StartOverlay
scene="a"
v-if="!start"
@start="start = true"
/>
<!-- 手机拍照界面 -->
<div class="absolute bottom-0 w-full h-[766px]" v-else>
<div
class="absolute bottom-0 w-full h-[766px]"
v-else
>
<img
class="phone-frame absolute w-full h-[766px] z-10 pointer-events-none"
src="@/assets/images/shooting/shoot.png"
@@ -189,7 +212,7 @@ const capturePhoto = async () => {
:class="{
'transition-transform duration-300': !isDragging,
'cursor-grab': !isDragging,
'cursor-grabbing': isDragging
'cursor-grabbing': isDragging,
}"
:style="{ transform: getTransform() }"
src="@/assets/images/film/shooting/龙美术馆清晰图.png"
@@ -216,7 +239,9 @@ const capturePhoto = async () => {
<div
class="shooting-decor relative pointer-events-none top-[19.2%] left-[48.3%] -translate-x-1/2 w-[871px] h-[420px] bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: getBackgroundImage('shooting/拍摄装饰.png') }"
:style="{
backgroundImage: getBackgroundImage('shooting/拍摄装饰.png'),
}"
>
<!-- 仿苹果半圆盘缩放控件 -->
<div
@@ -240,13 +265,17 @@ const capturePhoto = async () => {
<!-- 圆心在容器右侧外部,形成左侧弧形 -->
<div
class="dial-wheel absolute top-1/2 right-[-480px] w-[600px] h-[600px] rounded-full bg-black/30 backdrop-blur-md border-[5px] border-white/20 transition-transform duration-100 ease-out origin-center cursor-grab active:cursor-grabbing"
:style="{ transform: `translateY(-50%) rotate(${dialRotation}deg)` }"
:style="{
transform: `translateY(-50%) rotate(${dialRotation}deg)`,
}"
>
<div
v-for="tick in dialTicks"
:key="tick.zoom"
class="absolute top-1/2 left-0 w-full h-0 flex justify-start items-center origin-center pointer-events-auto"
:style="{ transform: `translate(0, -50%) rotate(${tick.angle}deg)` }"
:style="{
transform: `translate(0, -50%) rotate(${tick.angle}deg)`,
}"
@click.stop="setZoom(tick.zoom)"
>
<!-- 刻度线本体 (位于圆盘左边缘) -->
@@ -255,17 +284,21 @@ const capturePhoto = async () => {
:class="{
'w-10 h-1 opacity-90': tick.isInteger,
'w-6 h-0.5 opacity-60': !tick.isInteger && tick.isMajor,
'w-3 h-[1px] opacity-40': !tick.isMajor
'w-3 h-[1px] opacity-40': !tick.isMajor,
}"
></div>
<!-- 刻度文字 -->
<div
v-if="tick.isInteger"
class="absolute left-[52px] text-white/90 font-bold text-lg drop-shadow-md"
:style="{ transform: `rotate(${-tick.angle - dialRotation}deg)` }"
:style="{
transform: `rotate(${-tick.angle - dialRotation}deg)`,
}"
>
<span
:class="{ 'text-yellow-400 scale-110': currentZoom === tick.zoom }"
:class="{
'text-yellow-400 scale-110': currentZoom === tick.zoom,
}"
class="block transition-all duration-200"
>
{{ tick.label }}
@@ -285,22 +318,39 @@ const capturePhoto = async () => {
</div>
<!-- 结果弹窗 -->
<Overlay :visible="showModal" @close="handleModalClose">
<div class="relative w-[1000px] h-[600px] flex items-center justify-center">
<Overlay
:visible="showModal"
@close="handleModalClose"
>
<div
class="relative w-[1000px] h-[600px] flex items-center justify-center"
>
<!-- 截图内容 -->
<div v-if="capturedImageUrl" class="bg-white p-2 rounded-md -rotate-6">
<img :src="capturedImageUrl" alt="拍摄结果" />
<div
v-if="capturedImageUrl"
class="bg-white p-2 rounded-md -rotate-6"
>
<img
:src="capturedImageUrl"
alt="拍摄结果"
/>
</div>
<!-- 操作按钮 -->
<div class="absolute bottom-1/2 -right-1/3 translate-y-1/2">
<ImgButton image="shooting/按钮-前往下一个取景地" @click="handleFinish" />
<ImgButton
image="shooting/按钮-前往下一个取景地"
@click="handleFinish"
/>
</div>
</div>
</Overlay>
</div>
<LoadingOverlay :visible="isLoading" text="正在出片中..." />
<LoadingOverlay
:visible="isLoading"
text="正在出片中..."
/>
</div>
</template>
@@ -324,7 +374,19 @@ const capturePhoto = async () => {
}
.mask-gradient {
mask-image: linear-gradient(to bottom, transparent, black 15%, black 85%, transparent);
-webkit-mask-image: linear-gradient(to bottom, transparent, black 15%, black 85%, transparent);
mask-image: linear-gradient(
to bottom,
transparent,
black 15%,
black 85%,
transparent
);
-webkit-mask-image: linear-gradient(
to bottom,
transparent,
black 15%,
black 85%,
transparent
);
}
</style>
@@ -1,6 +1,6 @@
<script lang="ts" setup>
import { ref } from 'vue'
import { getBackgroundImage, getStaticImage } from '@/utils'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/utils'
import { ImgButton } from '@/components/Button'
import { Overlay } from '@/components/Overlay'
import { useViewfinderB } from './useViewfinderB'
@@ -9,10 +9,13 @@ import StartOverlay from '../components/StartOverlay.vue'
import LoadingOverlay from '@/components/Loading/LoadingOverlay.vue'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { useRouter } from 'vue-router'
import { useFilmStore } from '@/stores/modules/film'
const router = useRouter()
const filmStore = useFilmStore()
const start = ref(false)
const showInstruction = ref<boolean>(false)
const isFirstVisit = !filmStore.isRouteViewed('FilmFilmingLocationB')
type ResizeDirection = 'nw' | 'ne' | 'sw' | 'se'
@@ -35,15 +38,29 @@ const handleModalClose = () => {
showModal.value = false
}
const handleFinish = (): void => {
showModal.value = false
router.push({ name: 'FilmMidtermShooting' })
}
// 自由取景框模式
const { viewfinderContainer, viewfinderBox, viewfinder, startDrag, startResize } = useViewfinderB()
const {
viewfinderContainer,
viewfinderBox,
viewfinder,
startDrag,
startResize,
} = useViewfinderB()
const handleStartDrag = (event: MouseEvent | TouchEvent): void => {
hideInstruction()
startDrag(event)
}
const handleStartResize = (direction: ResizeDirection, event: MouseEvent | TouchEvent): void => {
const handleStartResize = (
direction: ResizeDirection,
event: MouseEvent | TouchEvent,
): void => {
hideInstruction()
startResize(direction, event)
}
@@ -74,7 +91,7 @@ const captureScene = async (): Promise<void> => {
height: viewfinder.value.height,
scale: CANVAS_SCALE,
delay: UI_UPDATE_DELAY,
onDraw: ctx => {
onDraw: (ctx) => {
if (!backgroundImage.value) {
throw new Error('Background image not found')
}
@@ -96,15 +113,16 @@ const captureScene = async (): Promise<void> => {
0, // 目标 x
0, // 目标 y
viewfinder.value.width, // 目标 width
viewfinder.value.height // 目标 height
viewfinder.value.height, // 目标 height
)
},
onComplete: () => {
if (!isFirstVisit) return
// 延迟显示引导
setTimeout(() => {
showGuide.value = true
}, GUIDE_SHOW_DELAY)
}
},
})
}
@@ -113,8 +131,9 @@ const captureScene = async (): Promise<void> => {
*/
const handleComplete = () => {
showGuide.value = false
filmStore.markViewed('FilmFilmingLocationB')
router.push({
name: 'Film'
name: 'Film',
})
}
</script>
@@ -124,11 +143,16 @@ const handleComplete = () => {
<div
class="relative w-full h-full bg-cover bg-center bg-no-repeat shooting-bg"
:style="{
backgroundImage: getBackgroundImage('film/shooting/油罐艺术公园模糊底图.png')
backgroundImage: getBackgroundImage(
'film/shooting/油罐艺术公园模糊底图.png',
),
}"
>
<!-- 标题 -->
<ImgButton image="film/shooting/标题-油罐艺术公园.png" class="title-button absolute left-0" />
<ImgButton
image="film/shooting/标题-油罐艺术公园.png"
class="title-button absolute left-0"
/>
<!-- 返回按钮 -->
<ImgButton
@@ -144,10 +168,17 @@ const handleComplete = () => {
操作说明:拖动取景框四角调整裁剪范围,点击“取景”键就完成啦!
</p>
<StartOverlay scene="b" v-if="!start" @start="handleStart" />
<StartOverlay
scene="b"
v-if="!start"
@start="handleStart"
/>
<!-- 取景 -->
<div class="absolute bottom-0 w-full h-full" v-else>
<div
class="absolute bottom-0 w-full h-full"
v-else
>
<!-- 隐藏的背景图片,用于 Canvas 绘制 -->
<img
ref="backgroundImage"
@@ -158,14 +189,17 @@ const handleComplete = () => {
/>
<!-- 取景框容器 -->
<div ref="viewfinderContainer" class="relative w-full h-full">
<div
ref="viewfinderContainer"
class="relative w-full h-full"
>
<!-- 四个方向的遮罩层 -->
<!-- 上遮罩 -->
<div
class="mask-layer absolute left-0 right-0 bg-black/60 pointer-events-none z-10"
:style="{
top: 0,
height: viewfinder.y + 'px'
height: viewfinder.y + 'px',
}"
></div>
<!-- 下遮罩 -->
@@ -173,7 +207,7 @@ const handleComplete = () => {
class="mask-layer absolute left-0 right-0 bg-black/60 pointer-events-none z-10"
:style="{
top: viewfinder.y + viewfinder.height + 'px',
bottom: 0
bottom: 0,
}"
></div>
<!-- 左遮罩 -->
@@ -182,7 +216,7 @@ const handleComplete = () => {
:style="{
top: viewfinder.y + 'px',
width: viewfinder.x + 'px',
height: viewfinder.height + 'px'
height: viewfinder.height + 'px',
}"
></div>
<!-- 右遮罩 -->
@@ -191,7 +225,7 @@ const handleComplete = () => {
:style="{
top: viewfinder.y + 'px',
left: viewfinder.x + viewfinder.width + 'px',
height: viewfinder.height + 'px'
height: viewfinder.height + 'px',
}"
></div>
@@ -203,13 +237,15 @@ const handleComplete = () => {
left: viewfinder.x + 'px',
top: viewfinder.y + 'px',
width: viewfinder.width + 'px',
height: viewfinder.height + 'px'
height: viewfinder.height + 'px',
}"
@mousedown="handleStartDrag"
@touchstart="handleStartDrag"
>
<!-- 九宫格辅助线 -->
<div class="grid-lines absolute inset-0 grid grid-cols-3 grid-rows-3">
<div
class="grid-lines absolute inset-0 grid grid-cols-3 grid-rows-3"
>
<div
v-for="i in 9"
:key="i"
@@ -218,7 +254,7 @@ const handleComplete = () => {
'border-t-0': i <= 3,
'border-b-0': i >= 7,
'border-l-0': (i - 1) % 3 === 0,
'border-r-0': i % 3 === 0
'border-r-0': i % 3 === 0,
}"
></div>
</div>
@@ -252,34 +288,62 @@ const handleComplete = () => {
class="capture-button-container absolute z-20 -translate-x-1/2 -translate-y-1/2"
:style="{
left: viewfinder.x + viewfinder.width / 2 + 'px',
top: viewfinder.y + viewfinder.height + 'px'
top: viewfinder.y + viewfinder.height + 'px',
}"
>
<ImgButton class="w-36" image="shooting/按钮-取景" @click="captureScene" />
<ImgButton
class="w-36"
image="shooting/按钮-取景"
@click="captureScene"
/>
</div>
</div>
<!-- 结果弹窗 -->
<Overlay :visible="showModal" @close="handleModalClose">
<div class="relative w-[1000px] h-[600px] flex items-center justify-center">
<Overlay
:visible="showModal"
@close="handleModalClose"
>
<div
class="relative w-[1000px] h-[600px] flex items-center justify-center"
>
<!-- 截图内容 -->
<div v-if="capturedImageUrl" class="bg-white p-2 rounded-md -rotate-6">
<img :src="capturedImageUrl" alt="拍摄结果" />
<div
v-if="capturedImageUrl"
class="bg-white p-2 rounded-md -rotate-6"
>
<img
:src="capturedImageUrl"
alt="拍摄结果"
/>
</div>
<!-- 操作按钮 -->
<!-- <div class="absolute bottom-1/2 -right-1/3 translate-y-1/2">
<ImgButton image="shooting/按钮-前往下一个取景地" @click="handleFinish" />
</div> -->
<!-- 完成按钮(非首次学习时显示) -->
<button
v-if="!isFirstVisit"
class="absolute -bottom-16 -rotate-6 ml-10 left-1/2 -translate-x-1/2 px-16 py-4 text-3xl font-bold text-white rounded-full bg-[#1b8fd8] shadow-xl hover:bg-[#1597d7] active:scale-95 transition-all"
@click="handleFinish"
>
完成
</button>
</div>
</Overlay>
</div>
<LoadingOverlay :visible="isLoading" text="正在出片中..." />
<LoadingOverlay
:visible="isLoading"
text="正在出片中..."
/>
<!-- 引导步骤 -->
<GuideSteps v-if="showGuide" positionOffsetY="80%" @complete="handleComplete">
<GuideStepsItem>实地取景已全部完成,现在跟着我走进现场开始正式的拍摄吧~</GuideStepsItem>
<GuideSteps
v-if="showGuide"
positionOffsetY="80%"
@complete="handleComplete"
>
<GuideStepsItem :audioSrc="getPublicPath('audio/py_2_022.MP3')">
实地取景已全部完成,现在跟着我走进现场,开始正式的拍摄吧!
</GuideStepsItem>
</GuideSteps>
</div>
</template>
@@ -3,7 +3,7 @@ import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { GuideSteps, type GuideStep } from '@/components/Guide'
import { ImgButton } from '@/components/Button'
import { getBackgroundImage, getPublicPath } from '@/utils'
import { getBackgroundImage, getPublicPath, getStaticImage } 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'
@@ -25,7 +25,7 @@ const steps = ref<GuideStep[]>([
{
id: '1',
audioSrc: getPublicPath('audio/py_4_015.MP3'),
content: '哇,徐汇滨江真的超适合取景!一起看看可以运用哪些构图方法吧~',
content: '哇,徐汇滨江真的超适合取景,一起看看可以运用哪些构图方法吧!',
highlightSelector: '.btn-1',
},
])
@@ -114,18 +114,34 @@ const back = () => {
/>
</div>
<div class="absolute left-[12%] top-1/2 -translate-y-[calc(50%-70px)]">
<ImgButton
class="btn-2"
image="film/shooting/龙美术馆入口.png"
@click="handleClickA"
/>
<div class="relative inline-block">
<ImgButton
class="btn-2"
image="film/shooting/龙美术馆入口.png"
@click="handleClickA"
/>
<img
v-if="filmStore.isRouteViewed('FilmFilmingLocationA')"
:src="getStaticImage('midterm/完成.png')"
class="absolute -top-8 right-6 w-24 pointer-events-none"
alt="已完成"
/>
</div>
</div>
<div class="absolute right-[12%] top-1/2 -translate-y-[calc(50%-70px)]">
<ImgButton
class="btn-3"
image="film/shooting/油罐艺术公园入口.png"
@click="handleClickB"
/>
<div class="relative inline-block">
<ImgButton
class="btn-3"
image="film/shooting/油罐艺术公园入口.png"
@click="handleClickB"
/>
<img
v-if="filmStore.isRouteViewed('FilmFilmingLocationB')"
:src="getStaticImage('midterm/完成.png')"
class="absolute -top-8 right-14 w-24 pointer-events-none"
alt="已完成"
/>
</div>
</div>
<div class="absolute bottom-4 left-4 btn-1">
<ImgButton
+67 -24
View File
@@ -5,7 +5,7 @@ import { ImgButton } from '@/components/Button'
import { StarRating } from '@/components/Rating'
import { useRouter } from 'vue-router'
import BackToTopIcon from '@/assets/images/components/置顶.png'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { GuideSteps, type GuideStep } from '@/components/Guide'
import { useFilmStore } from '@/stores/modules/film'
const router = useRouter()
@@ -40,22 +40,67 @@ const showSittingPerson = computed<boolean>(() => {
const visible = ref<boolean>(false)
const guideKeyPrefix = 'film-practice-page'
//每一页的引导词
const steps = ref([
'欢迎来到实战操作台! 这里的内容很丰富,滚动鼠标就能浏览到全部内容啦~',
'点击【我的创作笔记】,编写创作思路~',
'点击【查看】按钮,欣赏他人实战作品~',
])
const pageGuideSteps: Record<number, GuideStep[]> = {
0: [
{
id: 'film-practice-page-0-2',
content: '这里可以查看任务描述。',
highlightSelector: '.desc-img',
positionOffsetY: '87%',
audioSrc: '/audio/py_6_002.MP3',
},
{
id: 'film-practice-page-0-3',
content: '这里可以查看任务具体要求。',
highlightSelector: '.task-btn',
positionOffsetY: '50%',
audioSrc: '/audio/py_6_003.MP3',
},
{
id: 'film-practice-page-0-4',
content: '点击下滑箭头,可查看更多学习与实践内容。',
highlightSelector: '.down-scroll-hint',
audioSrc: '/audio/py_6_005.MP3',
},
],
1: [
{
id: 'film-practice-page-1-1',
content: '点击【我的创作笔记】,记录自己的创作思路与作品说明。',
highlightSelector: '.my-notes-btn',
audioSrc: '/audio/py_6_004.MP3',
},
],
2: [
{
id: 'film-practice-page-2-1',
content: '点击【查看】按钮,欣赏学长学姐们的优秀实践作品。',
highlightSelector: '.view-btn',
positionOffsetY: '50%',
audioSrc: '/audio/py_6_006.MP3',
},
{
id: 'film-practice-page-2-3',
content: '点击向上按钮,可快速返回实战操作台首页。',
highlightSelector: '.back-to-top-btn',
positionOffsetY: '50%',
audioSrc: '/audio/py_6_009.MP3',
},
],
}
const currentGuideKey = computed(() => `${guideKeyPrefix}-${currentPage.value}`)
const contentSteps = computed(() => {
return steps.value[currentPage.value]
})
const currentGuideSteps = computed(
() => pageGuideSteps[currentPage.value] ?? [],
)
const showPageGuide = (): void => {
if (filmStore.guideShown[currentGuideKey.value]) return
visible.value = true
}
const handlePageGuideComplete = (): void => {
visible.value = false
filmStore.markGuideShown(currentGuideKey.value)
}
@@ -76,8 +121,7 @@ const worksList = ref<WorkItem[]>([
title: '学生案例',
stars: 4,
path: '/video/film/practice/film_anli1.mp4',
description:
'作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介',
description: '27084 施颖雯 杨可馨 胡悦莹 陆佳慧 雷鑫丽',
},
])
@@ -226,16 +270,16 @@ onUnmounted(() => {
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
/>
<!-- 实战操作台 -->
<!-- 操作描述 -->
<div class="w-full h-[1080px] bg-[#F5EFE6] relative">
<ImgButton
class="absolute left-40 bottom-14 z-10"
class="task-btn absolute left-40 bottom-14 z-10"
image="vlog/practice/查看任务要求"
@click="scrollToNextPage"
/>
<!-- img -->
<img
class="absolute left-0 top-0 z-10"
class="desc-img absolute left-0 top-0 z-10"
draggable="false"
:src="getStaticImage('film/practice/电影')"
alt=""
@@ -253,7 +297,7 @@ onUnmounted(() => {
:style="{ backgroundImage: getBackgroundImage('vlog/practice/背景2') }"
>
<ImgButton
class="absolute right-20 bottom-0 z-99"
class="my-notes-btn absolute right-20 bottom-0 z-99"
image="vlog/practice/我的创作笔记btn"
path="/film/practice/my-notes"
/>
@@ -298,7 +342,7 @@ onUnmounted(() => {
:style="{ transform: `translateX(-${worksScrollIndex * 509}px)` }"
>
<div
v-for="work in worksList"
v-for="(work, index) in worksList"
:key="work.id"
class="w-[503px] h-[722px] flex-shrink-0 rounded-[40px] overflow-hidden bg-[#FFF8F0] py-8 px-6"
>
@@ -329,6 +373,7 @@ onUnmounted(() => {
</p>
<!-- 查看按钮 -->
<ImgButton
:class="index === 0 ? 'view-btn' : ''"
image="vlog/practice/查看"
:path="`/film/practice/video/${encodeURIComponent(work.path)}`"
/>
@@ -372,7 +417,7 @@ onUnmounted(() => {
<!-- 回到顶部按钮 - 最后一页显示 -->
<img
v-show="isLastPage"
class="absolute bottom-10 left-1/2 -translate-x-1/2 cursor-pointer transition-all duration-300 hover:scale-105"
class="back-to-top-btn absolute bottom-10 left-1/2 -translate-x-1/2 cursor-pointer transition-all duration-300 hover:scale-105"
:src="BackToTopIcon"
@click="scrollToTop"
/>
@@ -406,11 +451,9 @@ onUnmounted(() => {
<GuideSteps
v-if="visible"
position-offset-y="70%"
@complete="visible = false"
>
<GuideStepsItem>{{ contentSteps }}</GuideStepsItem>
</GuideSteps>
:steps="currentGuideSteps"
@complete="handlePageGuideComplete"
/>
</div>
</template>
+9 -4
View File
@@ -45,17 +45,17 @@ const defaultEvaluationItems: PracticeRatingItem[] = [
{
title: '画面氛围',
description: '画面具有明确情绪或空间气质,整体风格统一',
score: 4,
score: 0,
},
{
title: '拍摄方法',
description: '拍摄方式选择合理,镜头语言服务于画面表达',
score: 2,
score: 0,
},
{
title: '剪辑逻辑',
description: '剪辑节奏清晰,镜头衔接自然,有整体感',
score: 5,
score: 0,
},
{
title: '创作意识',
@@ -65,7 +65,7 @@ const defaultEvaluationItems: PracticeRatingItem[] = [
{
title: '综合观感',
description: '作品具有“电影感”,完成度高',
score: 5,
score: 0,
},
]
@@ -74,7 +74,11 @@ const evaluationItems = ref<PracticeRatingItem[]>(
)
const handleSave = (): void => {
const content = noteContent.value.trim()
if (!content) return
store.setPracticeNoteContent(noteContent.value)
store.markViewed('FilmPractice')
showSuccess.value = true
window.clearTimeout(successTimer)
successTimer = window.setTimeout(() => {
@@ -121,6 +125,7 @@ onBeforeUnmount(() => {
<div class="absolute bottom-24 left-1/2 -translate-x-1/2 flex gap-60">
<ImgButton
image="vlog/practice/保存"
:disabled="disabled"
@click="handleSave"
/>
<ImgButton
+13 -9
View File
@@ -5,7 +5,7 @@ import { ImgButton } from '@/components/Button'
import PracticeRatingModal, {
type PracticeRatingItem,
} from '@/components/PracticeRatingModal'
import { IMAGE_PATHS, getPublicPath } from '@/utils'
import { IMAGE_PATHS, getPublicPath, getVideoPoster } from '@/utils'
import { computed, ref } from 'vue'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { useFilmStore } from '@/stores/modules/film'
@@ -15,7 +15,7 @@ const store = filmStore
const guideKey = 'film-practice-video-rating'
const showGuide = ref(!filmStore.guideShown[guideKey])
if (showGuide.value) {
const handleRatingGuideComplete = () => {
filmStore.markGuideShown(guideKey)
}
@@ -47,17 +47,17 @@ const defaultEvaluationItems: PracticeRatingItem[] = [
{
title: '画面氛围',
description: '画面具有明确情绪或空间气质,整体风格统一',
score: 4,
score: 0,
},
{
title: '拍摄方法',
description: '拍摄方式选择合理,镜头语言服务于画面表达',
score: 2,
score: 0,
},
{
title: '剪辑逻辑',
description: '剪辑节奏清晰,镜头衔接自然,有整体感',
score: 5,
score: 0,
},
{
title: '创作意识',
@@ -67,7 +67,7 @@ const defaultEvaluationItems: PracticeRatingItem[] = [
{
title: '综合观感',
description: '作品具有“电影感”,完成度高',
score: 5,
score: 0,
},
]
@@ -99,12 +99,13 @@ const handleBack = (): void => {
@click="handleBack"
/>
<ImgButton
class="absolute right-0 bottom-10 z-50 btn-1"
class="absolute right-0 top-1/2 -translate-y-1/2 z-50"
image="vlog/practice/评分"
@click="showModal = true"
/>
<Video
:src="getPublicPath(decodeURIComponent(props.path))"
:poster="getVideoPoster(getPublicPath(decodeURIComponent(props.path)))"
:volume="1"
:muted="false"
:controls="true"
@@ -122,10 +123,13 @@ const handleBack = (): void => {
/>
<GuideSteps
:visible="showGuide"
:highlight-selector="'.btn-1'"
highlight-selector=".btn-1"
position-offset-y="60%"
@complete="handleRatingGuideComplete"
>
<GuideStepsItem>点击这里可以给作品评分噢~</GuideStepsItem>
<GuideStepsItem audio-src="/audio/py_6_008.MP3">
点击【评分】,为你喜爱的作品送上五星好评。
</GuideStepsItem>
</GuideSteps>
</div>
</template>
+34 -6
View File
@@ -1,5 +1,10 @@
<script lang="ts" setup>
import { IMAGE_PATHS, getPublicPath, getStaticImage } from '@/utils'
import {
IMAGE_PATHS,
getPublicPath,
getStaticImage,
getVideoPoster,
} from '@/utils'
import { ImgButton } from '@/components/Button'
import { Video } from '@/components'
import { useRouter } from 'vue-router'
@@ -14,12 +19,17 @@ const back = () => {
}
const { scale, offsetX, offsetY } = useScale()
const handleOpenImages = () => {
ImagePreview({
images: [getStaticImage('film/脚本编写-拍摄脚本')],
startIndex: 0,
maskClosable: true,
getScaleMetrics: () => ({ scale: scale.value, offsetX: offsetX.value, offsetY: offsetY.value })
getScaleMetrics: () => ({
scale: scale.value,
offsetX: offsetX.value,
offsetY: offsetY.value,
}),
})
}
</script>
@@ -32,18 +42,36 @@ const handleOpenImages = () => {
@click="back"
/>
<!-- 背景 -->
<img class="absolute inset-0" :src="getStaticImage('img/背景.png')" alt="" />
<img
class="absolute inset-0"
:src="getStaticImage('img/背景.png')"
alt=""
/>
<!-- 标题 -->
<img class="absolute top-5 z-10" :src="getStaticImage('img/脚本编写-标题.png')" alt="" />
<img
class="absolute top-5 z-10"
:src="getStaticImage('img/脚本编写-标题.png')"
alt=""
/>
<!-- 内容 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] -->
<div class="relative h-full pt-28 overflow-y-scroll">
<img :src="getStaticImage('film/img/脚本编写.png')" alt="" />
<img
:src="getStaticImage('film/img/脚本编写.png')"
alt=""
/>
<div class="absolute top-60 left-36 w-[1000px] h-[700px] rounded-2xl overflow-hidden">
<div
class="absolute top-60 left-36 w-[1000px] h-[700px] rounded-2xl overflow-hidden"
>
<Video
:src="getPublicPath('video/film/script-writing/jiaoben.mp4')"
:poster="
getVideoPoster(
getPublicPath('video/film/script-writing/jiaoben.mp4'),
)
"
playsinline
controls
:volume="1"
+42 -12
View File
@@ -1,23 +1,26 @@
<script lang="tsx" setup>
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { ref, computed, onMounted } from 'vue'
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ImagePreview } from '@/components'
import { useScale } from '@/composables/useScale'
import { useFilmStore } from '@/stores'
const router = useRouter()
const filmStore = useFilmStore()
const sampleGuideKey = 'film-script-writing-sample-guide'
const showDoubao = ref(false)
const steps = [
{
id: 'step1',
content: '先认识 Vlog:点击右侧「1.vlog初识」继续',
highlightSelector: '.img-switch-item--vlog'
highlightSelector: '.img-switch-item--vlog',
},
{
id: 'step2',
content: '这是主要内容区域的边框',
highlightSelector: '.work-border'
}
highlightSelector: '.work-border',
},
]
const back = () => {
@@ -26,7 +29,7 @@ const back = () => {
const clickdoubao = () => {
router.push({
name: 'Film'
name: 'Film',
})
}
@@ -35,13 +38,23 @@ function openImages(urls: string[], startIndex = 0) {
images: urls,
startIndex,
maskClosable: true,
getScaleMetrics: () => ({ scale: scale.value, offsetX: offsetX.value, offsetY: offsetY.value }),
getScaleMetrics: () => ({
scale: scale.value,
offsetX: offsetX.value,
offsetY: offsetY.value,
}),
onClose: () => {
if (filmStore.guideShown[sampleGuideKey]) return
showDoubao.value = true
}
},
})
}
const handleSampleGuideComplete = () => {
showDoubao.value = false
filmStore.markGuideShown(sampleGuideKey)
}
const { scale, offsetX, offsetY } = useScale()
onMounted(() => {
openImages([getStaticImage('vlog/脚本切图/我的志愿者手记拍摄脚本.png')])
@@ -52,18 +65,27 @@ onMounted(() => {
<!-- 背景图片 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat writing-bg"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.BIAOGEBG) }"
:style="{
backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.BIAOGEBG),
}"
>
<div>
<div class="writing-title">
<img src="@/assets/images/vlog/标题栏-中间.png" alt="" class="titledibu" />
<img
src="@/assets/images/vlog/标题栏-中间.png"
alt=""
class="titledibu"
/>
<img
src="@/assets/images/vlog/制作变焦镜/文字标题/《我的志愿者手记》分镜头脚本.png"
alt=""
class="titletext"
/>
</div>
<div class="doubao" @click="clickdoubao">
<div
class="doubao"
@click="clickdoubao"
>
<img
src="@/assets/images/vlog/制作变焦镜/ai脚本视频占位图.png"
alt=""
@@ -71,7 +93,11 @@ onMounted(() => {
/>
</div>
<div class="shooting-return">
<img src="@/assets/images/shooting/返回.png" alt="" @click="back" />
<img
src="@/assets/images/shooting/返回.png"
alt=""
@click="back"
/>
</div>
</div>
@@ -104,9 +130,13 @@ onMounted(() => {
:highlight-padding="12"
:highlight-radius="20"
position-offset-y="85%"
@close="handleSampleGuideComplete"
>
<GuideStepsItem>
<img src="@/assets/images/components/引导/ai脚本工具引导.png" alt="" />
<img
src="@/assets/images/components/引导/ai脚本工具引导.png"
alt=""
/>
</GuideStepsItem>
</GuideSteps>
</div>
+49 -40
View File
@@ -5,22 +5,27 @@ 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 { Video, ImagePreview } from '@/components'
import { useScale } from '@/composables/useScale'
import QuestionQuiz, {
type Question,
} from '@/components/QuestionQuiz/QuestionQuiz.vue'
import { getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
import {
getPublicPath,
getStaticImage,
IMAGE_PATHS,
getVideoPoster,
} from '@/utils'
import { useFilmStore } from '@/stores'
const router = useRouter()
const route = useRoute()
const film = useFilmStore()
const { scale, offsetX, offsetY } = useScale()
const visibleGuide = ref(true)
const visibleGuide2 = ref(true)
// 引导步骤索引
const guideStepIndex = ref(0)
@@ -189,39 +194,49 @@ const handleVideoChange = () => {
currentVideoIndex.value++
currentNavigationStep.value = currentVideoIndex.value
} else {
if (film.guideShown['work-profession-complete']) {
return
}
status.value = true
steps.value = [
{
id: 'step1',
audioSrc: getPublicPath('audio/py_2_023.MP3'),
content:
'恭喜你,已完成专业剖析的学习,点击【查看脚本】回顾视频脚本吧!',
'恭喜你!完成了专业剖析的全部内容,点击【返回】按钮查看本案例的【思路提点】。',
},
]
return
}
nextTick(() => {
// 使用受控播放来触发下一个视频自动播放
playing.value = true
})
playing.value = true
}
const handleClose = () => {
if (key.value === 'idea') {
film.markWorkVideoWatched('idea')
film.setFilmUnlockProgress(1)
router.push({
name: 'Film',
query: { guide: 'return' },
})
return
}
router.back()
film.requestWorkCompletionCheck()
router.push({ name: 'FilmWork' })
}
const image = computed(() => route.query.image as string)
const key = computed(() => route.query.key as string)
// const label = computed(() => route.query.label as string)
const ideaImage = computed(() => getStaticImage('film/work/思路提点全屏'))
const handleIdeaImageClick = () => {
ImagePreview({
images: [ideaImage.value],
maskClosable: true,
getScaleMetrics: () => ({
scale: scale.value,
offsetX: offsetX.value,
offsetY: offsetY.value,
}),
})
}
const handleClick = () => {}
const handlePlay = () => {
@@ -252,8 +267,10 @@ const onEnded = () => {
const handleComplete = () => {
if (key.value === 'profession') {
film.markGuideShown('work-profession-complete')
film.markWorkVideoWatched('profession')
if (!film.isFilmUnlock) film.setIsFilmUnlock(true)
film.requestWorkCompletionCheck()
router.push({
name: 'FilmWork',
})
@@ -266,9 +283,7 @@ const handleStepClick = (index: number) => {
showQuiz.value = false
currentVideoIndex.value = index
currentNavigationStep.value = index
nextTick(() => {
playing.value = true
})
playing.value = true
}
const handleStepNavigationClose = () => {
@@ -282,8 +297,13 @@ const handleQuestionsComplete = () => {
handleVideoChange()
}
const isProfessionGuideVisible = computed(
() => key.value === 'profession' && !film.guideShown['work-profession-intro'],
)
// 专业剖析引导结束后自动播放视频
const handleProfessionGuideComplete = () => {
film.markGuideShown('work-profession-intro')
visibleGuide.value = false
nextTick(() => {
playing.value = true
@@ -309,7 +329,11 @@ onBeforeRouteLeave((to) => {
return false
})
onMounted(() => {})
onMounted(() => {
if (!isProfessionGuideVisible.value) {
playing.value = true
}
})
</script>
<template>
@@ -349,6 +373,7 @@ onMounted(() => {})
<Video
v-if="key === 'case' || key === 'profession'"
:src="resolvedSrc"
:poster="getVideoPoster(resolvedSrc)"
v-model="playing"
:muted="false"
:volume="1"
@@ -374,9 +399,11 @@ onMounted(() => {})
<div
v-else-if="key === 'idea'"
:src="image"
class="cursor-pointer"
@click="handleIdeaImageClick"
>
<img
:src="getStaticImage('film/思路提点全屏')"
:src="ideaImage"
alt=""
/>
</div>
@@ -404,7 +431,7 @@ onMounted(() => {})
<!-- 引导与高亮 -->
<GuideSteps
:highlight-selector="guideStepIndex === 2 ? '.step-navigation-btn' : ''"
v-if="visibleGuide && key === 'profession'"
v-if="visibleGuide && isProfessionGuideVisible"
position-offset-y="85%"
@step-change="(_: any, index: number) => (guideStepIndex = index)"
@complete="handleProfessionGuideComplete"
@@ -451,24 +478,6 @@ onMounted(() => {})
@continue="handleContinueLearning"
@leave="handleConfirmLeave"
/>
<GuideSteps
highlight-selector=""
v-if="visibleGuide2 && key === 'idea'"
position-offset-y="85%"
@complete="handleComplete"
>
<GuideStepsItem :audio-src="getPublicPath('audio/py_4_007.MP3')">
一个好镜头,既要好看,又要好懂 ——
既有视觉吸引力,又能把信息清晰地传达出来。
</GuideStepsItem>
<GuideStepsItem :audio-src="getPublicPath('audio/py_4_008.MP3')">
当这些条件被满足,一个镜头就有了“高级感”。
</GuideStepsItem>
<GuideStepsItem :audio-src="getPublicPath('audio/py_4_009.MP3')">
而优秀的短视频,就是靠无数这样的好镜头累积而成的。
</GuideStepsItem>
</GuideSteps>
</template>
<style scoped>
@@ -10,6 +10,7 @@
<script setup lang="ts">
import { ImageSwitchList } from '@/components/Switcher'
import { useFilmStore } from '@/stores'
import { computed } from 'vue'
type WorkTab = 'case' | 'profession' | 'idea'
@@ -26,41 +27,50 @@ interface Emits {
const props = withDefaults(defineProps<Props>(), {
modelValue: 'case',
itemClass: 'w-[260px] h-[64px] rounded-[18px]'
itemClass: 'w-[260px] h-[64px] rounded-[18px]',
})
const emit = defineEmits<Emits>()
const film = useFilmStore()
const getStatusImage = (key: WorkTab) =>
film.workVideosWatched[key]
? 'work/任务取景框-完成'
: 'work/任务取景框-未完成'
const innerValue = computed({
get: () => props.modelValue as WorkTab,
set: (val: WorkTab) => emit('update:modelValue', val)
set: (val: WorkTab) => emit('update:modelValue', val),
})
const items = [
const items = computed(() => [
// { key: 'vlog', activeImage: 'work/vlog初识-当前', inactiveImage: 'work/vlog初识-未选中' },
{
key: 'case',
label: '案例赏析',
activeImage: 'work/选中_带三角',
inactiveImage: 'work/未选中_带三角'
inactiveImage: 'work/未选中_带三角',
statusImage: getStatusImage('case'),
},
{
key: 'profession',
label: '专业剖析',
activeImage: 'work/选中_带三角',
inactiveImage: 'work/未选中_带三角'
inactiveImage: 'work/未选中_带三角',
statusImage: getStatusImage('profession'),
},
{
key: 'idea',
label: '思路提点',
activeImage: 'work/选中_带三角',
inactiveImage: 'work/未选中_带三角'
}
]
inactiveImage: 'work/未选中_带三角',
statusImage: getStatusImage('idea'),
},
])
const onChange = (val: string) => emit('change', val as WorkTab)
defineExpose({
items
items,
})
</script>
+66 -13
View File
@@ -1,6 +1,6 @@
<script lang="tsx" name="FilmWork" setup>
defineOptions({ name: 'FilmWork' })
import { ref, computed } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import {
getBackgroundImage,
IMAGE_PATHS,
@@ -9,7 +9,7 @@ import {
} from '@/utils'
import { ImgButton } from '@/components/Button'
import WorkSideSwitcher from './components/WorkSideSwitcher.vue'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { GuideSteps, GuideStepsItem, type GuideStep } from '@/components/Guide'
import { useRouter } from 'vue-router'
import { useScale } from '@/composables/useScale'
import { ImagePreview } from '@/components'
@@ -30,6 +30,53 @@ const workSideSwitcherRef = ref<InstanceType<typeof WorkSideSwitcher>>()
const isGuideFilmTask = computed(() => film.isGuideFilmTask)
const isFilmUnlock = computed(() => film.isFilmUnlock)
const showReturnGuide = ref(false)
const allWorkCompleted = computed(() =>
['case', 'profession', 'idea'].every((key) =>
Boolean(film.workVideosWatched[key as WorkTab]),
),
)
const returnSteps: GuideStep[] = [
{
id: 'idea-step1',
content:
'一个好镜头,既要好看,又要好懂 —— 既有视觉吸引力,又能把信息清晰地传达出来。',
audioSrc: getPublicPath('audio/py_4_007.MP3'),
},
{
id: 'idea-step2',
content: '当这些条件被满足,一个镜头就有了“高级感”。',
audioSrc: getPublicPath('audio/py_4_008.MP3'),
},
{
id: 'idea-step3',
content: '而优秀的短视频,就是靠无数这样的好镜头累积而成的。',
audioSrc: getPublicPath('audio/py_4_009.MP3'),
},
]
const handleReturnGuideComplete = () => {
showReturnGuide.value = false
film.setFilmUnlockProgress(1)
router.replace({ name: 'Film', query: { guide: 'return' } })
}
const completeWorkFlow = () => {
if (!allWorkCompleted.value || !film.isGuideFilmReturn) return
showReturnGuide.value = true
}
watch(allWorkCompleted, (completed, wasCompleted) => {
if (!completed || wasCompleted) return
completeWorkFlow()
})
onMounted(() => {
if (!film.consumeWorkCompletionCheck()) return
if (!film.isGuideFilmReturn) return
completeWorkFlow()
})
//切换图片
const sideSwitcherItems = ref([
@@ -37,19 +84,19 @@ const sideSwitcherItems = ref([
key: 'case',
name: '案例赏析',
description: '物件春秋-上海钟表文化科普馆',
image: 'film/案件赏析小屏',
image: 'film/work/案件赏析小屏',
},
{
key: 'profession',
name: '专业剖析',
description: '物件春秋-上海钟表文化科普馆',
image: 'film/专业剖析',
image: 'film/work/专业剖析小屏',
},
{
key: 'idea',
name: '思路提点',
description: '物件春秋-上海钟表文化科普馆',
image: 'film/思路提点小屏',
image: 'film/work/思路提点小屏',
},
])
@@ -58,10 +105,6 @@ const currentItem = computed(() => {
return sideSwitcherItems.value.find((item) => item.key === selected.value)
})
const currentHighlight = computed(() =>
stepIndex.value === 2 ? '.work-border' : null,
)
const handleStepChange = (_: any, index: number) => {
stepIndex.value = index
}
@@ -185,7 +228,7 @@ function openImages(urls: string[], startIndex = 0) {
</div>
<!-- 左侧选项切换 -->
<div class="absolute right-4 top-56 z-20">
<div class="absolute right-0 top-1/2 -translate-y-1/2 z-20">
<WorkSideSwitcher
ref="workSideSwitcherRef"
v-model="selected"
@@ -195,18 +238,18 @@ function openImages(urls: string[], startIndex = 0) {
</div>
<!-- 任务取景按钮 -->
<ImgButton
<!-- <ImgButton
class="absolute right-14 bottom-10"
:image="
isFilmUnlock ? IMAGE_PATHS.WORK.TASK_BTN_OK : IMAGE_PATHS.WORK.TASK_BTN
"
@click="handleWorkClick"
/>
/> -->
<!-- 引导与高亮 -->
<GuideSteps
v-if="isGuideFilmTask"
:highlight-selector="currentHighlight"
:highlight-selector="stepIndex === 2 ? '.work-border' : null"
:highlight-padding="12"
:highlight-radius="20"
@step-change="handleStepChange"
@@ -225,6 +268,16 @@ function openImages(urls: string[], startIndex = 0) {
</GuideStepsItem>
</GuideSteps>
<GuideSteps
v-if="showReturnGuide"
:steps="returnSteps"
:highlight-padding="12"
:highlight-radius="20"
@complete="handleReturnGuideComplete"
@skip="handleReturnGuideComplete"
position-offset-y="85%"
/>
<!-- 引导词按钮 -->
<ImgButton
class="absolute w-[120px] bottom-0 right-0 z-10"
+242 -55
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, nextTick, onMounted } from 'vue'
import { computed, ref, onMounted, onActivated, nextTick } from 'vue'
import { useRouter, type RouteRecordName } from 'vue-router'
import {
useUserStore,
@@ -27,12 +27,28 @@ import {
getOverallStudyProgress,
getProductionModuleProgress,
} from './components/studyProgressUtils'
import {
basicElementsEditingVideoKeys,
basicElementsFoundationVideoKeys,
basicElementsShootingVideoKeys,
} from '@/config/moduleStepMap'
const compositionVideoKeys = basicElementsFoundationVideoKeys.filter((key) =>
key.startsWith('basic-composition:'),
)
const lightVideoKeys = basicElementsFoundationVideoKeys.filter((key) =>
key.startsWith('basic-light:'),
)
const cameraMovementVideoKeys = basicElementsFoundationVideoKeys.filter((key) =>
key.startsWith('basic-camera-movement:'),
)
type GuideChildNode = {
id: number
label: string
icon: string
routeName: RouteRecordName
videoKeys: readonly string[]
}
type GuideNode = {
@@ -44,45 +60,38 @@ type GuideNode = {
hasChildren: boolean
routeName?: RouteRecordName
children?: GuideChildNode[]
videoKeys: readonly string[]
}
const actionGuideKey = 'home-action-guide'
const activeActionGuide = ref<'home' | 'unlocked' | 'element' | null>(null)
const homeGuideKey = 'home-action-home-guide'
const completionGuideKey = 'home-action-completion-guide'
const modulesGuideKey = 'home-action-modules-guide'
const activeActionGuide = ref<
'home' | 'unlocked' | 'element' | 'completion' | 'modules' | null
>(null)
const activeGuideStepHasHighlight = ref(false)
const pendingElementGuide = ref(false)
const studyProgressVisible = ref(false)
const homeGuideSteps: GuideStep[] = [
{
id: 'home-guide-navigation',
content: '点击新手导航按钮,可以展开/收起新手导航面板!',
content: '点击新手导航按钮,可以展开/收起新手导航面板!',
positionOffsetY: '50%',
highlightSelector: '.newUserGuide',
audioSrc: getPublicPath('audio/py_0_005.MP3'),
},
{
id: 'home-guide-locked-modules',
content: '新手导航模块全部完成后才能解锁三大进阶模块哦~',
content: '新手导航模块全部完成后才能解锁进阶模块哦!',
highlightSelector: '.newUserGuide',
positionOffsetY: '50%',
audioSrc: getPublicPath('audio/py_0_006.MP3'),
},
{
id: 'home-guide-module-switcher',
content:
'舞台划分为三大创作板块!点击 Vlog、访谈、电影的对应区域,右侧屏幕就会切换到对应板块!',
highlightSelector: '.module-switcher-guide',
positionOffsetY: '85%',
audioSrc: getPublicPath('audio/py_0_007.MP3'),
},
{
id: 'home-guide-tv',
content: '再点击右侧屏幕就会开启对应的板块学习哦!',
highlightSelector: '.tvGuide',
positionOffsetY: '80%',
audioSrc: getPublicPath('audio/py_0_008.MP3'),
},
{
id: 'home-guide-skill',
content: '点击技能树,可以一键回顾新手导航和三大创作板块所有的知识点哦!',
content: '点击技能树,可以回顾课程所有知识点哦!',
highlightSelector: '.skill-tree',
positionOffsetY: '50%',
audioSrc: getPublicPath('audio/py_0_009.MP3'),
@@ -103,7 +112,7 @@ const unlockedHomeGuideSteps: GuideStep[] = [
},
{
id: 'home-guide-start-node',
content: '选择任意节点开始学习~',
content: '选择任意节点开始学习!',
highlightSelector: '.guide-content',
positionOffsetY: '20%',
audioSrc: getPublicPath('audio/py_1_003.MP3'),
@@ -114,25 +123,92 @@ const elementGuideSteps: GuideStep[] = [
{
id: 'home-guide-start-element',
highlightSelector: '.child-node-buttons',
content: '选择任意要素开始学习~',
content: '选择任意要素开始学习!',
positionOffsetY: '20%',
audioSrc: getPublicPath('audio/py_1_004.MP3'),
},
]
const completionGuideSteps: GuideStep[] = [
{
id: 'home-guide-completion',
content: '新手导航模块全部完成啦!恭喜你,进阶任务已解锁!',
audioSrc: getPublicPath('audio/py_1_025.MP3'),
},
]
const modulesGuideSteps: GuideStep[] = [
{
id: 'home-guide-modules-switcher',
content:
'进阶任务分为三大创作板块!点击Vlog、访谈、电影模块对应的扇形区域,右侧大屏就会随之切换到对应板块!',
highlightSelector: '.module-switcher-guide',
positionOffsetY: '85%',
audioSrc: getPublicPath('audio/py_0_007.MP3'),
},
{
id: 'home-guide-modules-tv',
content: '再点击右侧大屏就会进入对应板块学习哦!',
highlightSelector: '.tvGuide',
positionOffsetY: '80%',
audioSrc: getPublicPath('audio/py_0_008.MP3'),
},
{
id: 'home-guide-modules-vlog-suggestion',
content: '如果你是初学者,建议你从Vlog模块开始学习!',
audioSrc: getPublicPath('audio/py_0_008_1.MP3'),
},
]
const actionGuideStepsMap = {
home: homeGuideSteps,
unlocked: unlockedHomeGuideSteps,
element: elementGuideSteps,
completion: completionGuideSteps,
modules: modulesGuideSteps,
}
const startActionGuide = (guide: keyof typeof actionGuideStepsMap) => {
activeGuideStepHasHighlight.value = Boolean(
actionGuideStepsMap[guide][0]?.highlightSelector,
)
activeActionGuide.value = guide
}
const handleGuideStepChange = (step: GuideStep | null) => {
activeGuideStepHasHighlight.value = Boolean(step?.highlightSelector)
}
const handleHomeGuideComplete = () => {
activeActionGuide.value = null
activeGuideStepHasHighlight.value = false
basicElements.markGuideShown(homeGuideKey)
}
const handleUnlockedHomeGuideComplete = () => {
activeActionGuide.value = null
activeGuideStepHasHighlight.value = false
basicElements.markUnlockedGuideFinished()
}
const handleElementGuideComplete = () => {
activeActionGuide.value = null
activeGuideStepHasHighlight.value = false
basicElements.markGuideShown(actionGuideKey)
}
const handleCompletionGuideComplete = () => {
activeActionGuide.value = null
activeGuideStepHasHighlight.value = false
basicElements.markGuideShown(completionGuideKey)
}
const handleModulesGuideComplete = () => {
activeActionGuide.value = null
activeGuideStepHasHighlight.value = false
basicElements.markGuideShown(modulesGuideKey)
}
const router = useRouter()
const user = useUserStore()
const film = useFilmStore()
@@ -154,9 +230,37 @@ const imageMap = {
//获取新手导航引导状态
const isGuideNewUser = computed(() => basicElements.isGuideNewUser)
const hasFinishedUnlockedGuide = computed(
() => basicElements.hasFinishedUnlockedGuide,
)
const hasFinishedActionGuide = computed(
() => basicElements.guideShown[actionGuideKey] === true,
)
const hasFinishedHomeGuide = computed(
() => basicElements.guideShown[homeGuideKey] === true,
)
const hasFinishedCompletionGuide = computed(
() => basicElements.guideShown[completionGuideKey] === true,
)
const hasFinishedModulesGuide = computed(
() => basicElements.guideShown[modulesGuideKey] === true,
)
const allBasicElementsVideoKeys = [
...basicElementsFoundationVideoKeys,
...basicElementsShootingVideoKeys,
...basicElementsEditingVideoKeys,
] as const
const isAllBasicElementsCompleted = computed(() =>
allBasicElementsVideoKeys.every(
(key) => basicElements.learningVideosWatched[key] === true,
),
)
const shouldShowGuideMask = computed(
() => !isAllBasicElementsCompleted.value || guideShow.value,
)
const shouldMakeGuideMaskTransparent = computed(
() => activeGuideStepHasHighlight.value && !guideShow.value,
)
// 预加载所有图片
const preloadImages = () => {
@@ -172,12 +276,8 @@ const preloadImages = () => {
// 事件处理函数
const handleOpenGuide = () => {
guideShow.value = true
if (
activeActionGuide.value !== 'home' &&
isGuideNewUser.value &&
!hasFinishedActionGuide.value
) {
activeActionGuide.value = 'unlocked'
if (activeActionGuide.value === null && !hasFinishedUnlockedGuide.value) {
startActionGuide('unlocked')
}
}
@@ -186,6 +286,15 @@ const handleCloseGuide = () => {
if (isGuideNewUser.value) {
basicElements.setIsGuideNewUser(false)
}
if (
isAllBasicElementsCompleted.value &&
!hasFinishedModulesGuide.value &&
activeActionGuide.value === null
) {
nextTick(() => {
startActionGuide('modules')
})
}
}
const handleModuleChange = (module: 'vlog' | 'film' | 'interview') => {
@@ -228,10 +337,14 @@ const totalStudyPercent = computed(() =>
basicElements.isBasicElementsUnlock,
basicElements.learningVideosWatched,
),
getProductionModuleProgress('vlog', vlog.viewed, vlog.workVideosWatched),
getProductionModuleProgress(
'vlog',
vlog.isRouteViewed,
vlog.workVideosWatched,
),
getProductionModuleProgress(
'interview',
interview.viewed,
interview.isRouteViewed,
interview.workVideosWatched,
),
getProductionModuleProgress(
@@ -242,15 +355,11 @@ const totalStudyPercent = computed(() =>
]),
)
const isSkillDisabled = computed(() => !hasFinishedActionGuide.value)
const handleOpenStudyProgress = () => {
studyProgressVisible.value = true
}
const handleSkill = () => {
if (isSkillDisabled.value) return
router.push({
name: 'Skill',
})
@@ -274,24 +383,28 @@ const nodes: GuideNode[] = [
x: 20,
y: 30,
hasChildren: true,
videoKeys: basicElementsFoundationVideoKeys,
children: [
{
id: 1,
label: '构图',
icon: 'home/action/新手导航构图',
routeName: 'Composition',
videoKeys: compositionVideoKeys,
},
{
id: 2,
label: '光',
icon: 'home/action/新手导航光',
routeName: 'Light',
videoKeys: lightVideoKeys,
},
{
id: 3,
label: '运镜',
icon: 'home/action/新手导航运镜',
routeName: 'CameraMovement',
videoKeys: cameraMovementVideoKeys,
},
],
},
@@ -303,6 +416,7 @@ const nodes: GuideNode[] = [
y: 60,
hasChildren: false,
routeName: 'BasicShootingSkills',
videoKeys: basicElementsShootingVideoKeys,
},
{
id: 3,
@@ -312,9 +426,19 @@ const nodes: GuideNode[] = [
y: 30,
hasChildren: false,
routeName: 'ShortVideoEditingSkills',
videoKeys: basicElementsEditingVideoKeys,
},
]
const guideStatusIcons = {
done: getStaticImage('home/新手导航/已完成'),
undone: getStaticImage('home/新手导航/未完成'),
}
const isVideoKeysCompleted = (keys: readonly string[]) => {
if (keys.length === 0) return false
return keys.every((key) => basicElements.learningVideosWatched[key] === true)
}
// 显示子节点的节点ID
const showChildrenNodeId = ref<number | null>(null)
@@ -337,14 +461,16 @@ const handleNodeClick = (node: GuideNode, event?: MouseEvent) => {
showChildrenNodeId.value = node.id
if (activeActionGuide.value !== 'unlocked' && !hasFinishedActionGuide.value) {
nextTick(() => {
requestAnimationFrame(() => {
activeActionGuide.value = 'element'
})
})
pendingElementGuide.value = true
}
}
const handleChildNodeAfterEnter = () => {
if (!pendingElementGuide.value) return
pendingElementGuide.value = false
startActionGuide('element')
}
// 处理子节点点击
const handleChildNodeClick = (childNode: GuideChildNode, event: MouseEvent) => {
event.stopPropagation()
@@ -356,34 +482,70 @@ const handleClickOutside = () => {
showChildrenNodeId.value = null
}
const handleReturnHome = () => {
router.push('/')
}
// 生命周期
onMounted(() => {
preloadImages()
if (isGuideNewUser.value && !hasFinishedActionGuide.value) {
activeActionGuide.value = 'home'
})
onActivated(() => {
if (activeActionGuide.value !== null) return
if (isAllBasicElementsCompleted.value && !hasFinishedCompletionGuide.value) {
startActionGuide('completion')
return
}
if (!hasFinishedHomeGuide.value && !hasFinishedActionGuide.value) {
startActionGuide('home')
}
})
</script>
<template>
<div class="w-full h-full relative">
<div class="w-full h-full relative perspective-[800px]">
<GuideSteps
v-if="activeActionGuide === 'home'"
:steps="homeGuideSteps"
position-offset-y="82%"
@complete="handleHomeGuideComplete"
@close="handleHomeGuideComplete"
@step-change="handleGuideStepChange"
/>
<GuideSteps
v-if="activeActionGuide === 'unlocked'"
:steps="unlockedHomeGuideSteps"
position-offset-y="82%"
@complete="handleUnlockedHomeGuideComplete"
@close="handleUnlockedHomeGuideComplete"
@step-change="handleGuideStepChange"
/>
<GuideSteps
v-if="activeActionGuide === 'element'"
:steps="elementGuideSteps"
position-offset-y="82%"
@complete="handleElementGuideComplete"
@close="handleElementGuideComplete"
@step-change="handleGuideStepChange"
/>
<GuideSteps
v-if="activeActionGuide === 'completion'"
:steps="completionGuideSteps"
position-offset-y="82%"
@close="handleCompletionGuideComplete"
@step-change="handleGuideStepChange"
/>
<GuideSteps
v-if="activeActionGuide === 'modules'"
:steps="modulesGuideSteps"
position-offset-y="82%"
@close="handleModulesGuideComplete"
@step-change="handleGuideStepChange"
/>
<!-- 返回首页按钮 -->
<ImgButton
class="absolute bottom-0 left-0 z-50"
image="home/action/返回引导页"
@click="handleReturnHome"
/>
<!-- 背景图片 -->
@@ -402,11 +564,14 @@ onMounted(() => {
<StudyProgressPopup v-model="studyProgressVisible" />
<div class="absolute top-[7.5rem] left-48 pointer-events-none">
<div
class="absolute top-[5.1rem] left-[168px] w-[110px] h-[116px] flex items-center justify-center pointer-events-none"
style="transform: rotateX(16deg) rotateY(-6deg) rotateZ(-12deg)"
>
<span class="text-5xl italic text-white">{{ totalStudyPercent }}%</span>
</div>
<div
<!-- <div
class="absolute top-4 left-4 flex items-center text-white text-xl bg-black/10 px-4 py-2 rounded-xl"
>
!测试阶段友情提示:课程全部完成后,若需重新观看之前的内容,请按
@@ -416,7 +581,7 @@ onMounted(() => {
@click="handleClearCache"
/>
清空缓存。
</div>
</div> -->
<!-- 选择模块 -->
<div
@@ -445,16 +610,15 @@ onMounted(() => {
<ImgButton
@click="handleSkill"
:image="IMAGE_PATHS.ACTION.SKILLS_BTN"
:disabled="isSkillDisabled"
class="absolute bottom-0 right-0 z-30 skill-tree"
class="absolute bottom-0 right-0 z-50 skill-tree"
/>
<!-- 新手指导遮罩 -->
<!-- 新手导航遮罩:未完成提示词时阻止进入模块;完成后打开导航时突出导航区域 -->
<div
v-if="isGuideNewUser && !hasFinishedActionGuide"
v-if="shouldShowGuideMask"
:class="[
'absolute inset-0 z-10',
activeActionGuide === 'home' ? 'bg-black/0' : 'bg-black/50',
shouldMakeGuideMaskTransparent ? 'bg-black/0' : 'bg-black/50',
]"
></div>
@@ -582,7 +746,7 @@ onMounted(() => {
>
<!-- 圆圈 -->
<div
class="w-24 h-24 rounded-full flex items-center justify-center text-5xl font-bold text-white z-10 relative transition-all duration-300 group-hover:bg-[#ff4d8c]"
class="relative w-24 h-24 rounded-full flex items-center justify-center text-5xl font-bold text-white z-10 transition-all duration-300 group-hover:bg-[#ff4d8c]"
:class="
showChildrenNodeId === node.id
? 'bg-[#ff4d8c] shadow-[0_0_20px_rgba(255,77,140,0.6)]'
@@ -595,6 +759,16 @@ onMounted(() => {
v-if="showChildrenNodeId === node.id"
class="absolute inset-0 rounded-full border-4 border-white/20 animate-pulse"
></div>
<!-- 完成状态指示 -->
<img
class="absolute -right-2 -top-2 w-10 h-10 pointer-events-none drop-shadow"
:src="
isVideoKeysCompleted(node.videoKeys)
? guideStatusIcons.done
: guideStatusIcons.undone
"
alt=""
/>
</div>
<!-- 标签 -->
<div
@@ -605,7 +779,10 @@ onMounted(() => {
</div>
<!-- 子节点图标(当点击三大基础要素时显示) -->
<Transition name="child-node-pop">
<Transition
name="child-node-pop"
@after-enter="handleChildNodeAfterEnter"
>
<div
v-if="node.hasChildren && showChildrenNodeId === node.id"
class="child-node-buttons absolute -top-48 left-1/2 z-20 flex -translate-x-1/2 flex-col items-center"
@@ -631,6 +808,16 @@ onMounted(() => {
:src="getStaticImage(childNode.icon)"
:alt="childNode.label"
/>
<!-- 完成状态指示 -->
<img
class="absolute right-6 top-2 z-20 w-9 h-9 pointer-events-none drop-shadow"
:src="
isVideoKeysCompleted(childNode.videoKeys)
? guideStatusIcons.done
: guideStatusIcons.undone
"
alt=""
/>
</div>
</div>
</div>
+136
View File
@@ -0,0 +1,136 @@
<template>
<Transition name="loading-fade">
<div
v-if="visible"
class="fixed inset-0 z-[10000] flex justify-center"
>
<img
:src="getStaticImage('components/loading/背景图层')"
class="absolute inset-0 w-full h-full object-cover"
alt=""
/>
<div class="mt-60 w-[80%] flex flex-col gap-20 z-10">
<div class="ml-40 relative z-10 flex items-center">
<img
:src="getStaticImage('components/loading/精彩内容即将呈现')"
class="object-contain"
alt="精彩内容即将呈现"
/>
<div class="inline-flex items-center gap-2 mt-12">
<img
v-for="i in 6"
:key="i"
:src="getStaticImage('components/loading/省略号')"
class="loading-dot"
:style="{ animationDelay: `${(i - 1) * 0.8}s` }"
alt=""
/>
</div>
</div>
<div class="relative w-full">
<div
class="absolute transition-all duration-300 ease-linear flex w-max flex-col items-center"
:style="{
left: `${progress}%`,
transform: 'translate(-50%, 30%)',
bottom: '0px',
}"
>
<img
class="w-[208px] h-[233px] max-w-none shrink-0"
:src="getStaticImage('components/loading/小龙人')"
alt=""
/>
<p class="progress-text">
{{ progressText }}
</p>
</div>
<div
class="w-full overflow-hidden bg-no-repeat py-2 px-[6px]"
:style="{
backgroundImage: `url(${getStaticImage('components/loading/进度条底板')})`,
backgroundSize: '100% 100%',
height: '78px',
}"
>
<div
class="h-full bg-no-repeat transition-all duration-300 ease-linear"
:style="{
width: `${progress}%`,
height: '64px',
backgroundImage: `url(${getStaticImage('components/loading/进度条亮')})`,
backgroundSize: 'auto 100%',
}"
/>
</div>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { getStaticImage } from '@/utils'
defineOptions({
name: 'HomeLoadingOverlay',
})
const props = withDefaults(
defineProps<{
visible: boolean
progress?: number
}>(),
{
progress: 0,
},
)
const progressText = computed(() => `${Math.round(props.progress)}%`)
</script>
<style scoped>
.loading-dot {
animation: dotPulse 0.9s ease-in-out infinite;
}
@keyframes dotPulse {
0%,
100% {
opacity: 0.15;
transform: scale(0.75);
}
50% {
opacity: 1;
transform: scale(1.25);
}
}
.progress-text {
font-size: 3rem;
font-weight: bold;
color: #fff;
-webkit-text-stroke: 4px #3394f4;
paint-order: stroke fill;
text-shadow:
0 0 8px #55b9f4,
0 0 16px #55b9f4,
0 0 32px #55b9f4,
0 0 48px #55b9f4;
letter-spacing: 0.1em;
margin-top: 0.25rem;
}
.loading-fade-enter-active,
.loading-fade-leave-active {
transition: opacity 0.4s ease;
}
.loading-fade-enter-from,
.loading-fade-leave-to {
opacity: 0;
}
</style>
@@ -44,7 +44,7 @@ interface Emits {
}
const props = withDefaults(defineProps<Props>(), {
modelValue: 'vlog'
modelValue: 'vlog',
})
const emit = defineEmits<Emits>()
@@ -63,14 +63,15 @@ const moduleConfigs: ModuleConfig[] = [
className: 'left-10 bottom-[240px] w-[500px] h-[100px] -rotate-[15deg]',
position: 'left-10 bottom-[240px]',
size: 'w-[500px] h-[100px]',
transform: '-rotate-[15deg]'
}
transform: '-rotate-[15deg]',
},
},
{
type: 'interview',
normalImage: IMAGE_PATHS.ACTION.MODULE_BTN2,
selectedImage: IMAGE_PATHS.ACTION.SELECTED_BTN2,
className: 'w-[1087px] h-[822px] left-[calc(50%+50px)] -translate-x-1/2 -bottom-4',
className:
'w-[1087px] h-[822px] left-[calc(50%+50px)] -translate-x-1/2 -bottom-4',
maskConfig: {
/**
* width: 200px;
@@ -82,8 +83,8 @@ const moduleConfigs: ModuleConfig[] = [
'left-[6em] bottom-[60px] w-[850px] h-[153px] bg-transparent [clip-path:polygon(50%_0%,_0%_100%,_100%_100%)]',
position: 'left-[16em] bottom-[50px]',
size: 'w-[500px] h-[100px]',
transform: ''
}
transform: '',
},
},
{
type: 'film',
@@ -91,12 +92,13 @@ const moduleConfigs: ModuleConfig[] = [
selectedImage: IMAGE_PATHS.ACTION.SELECTED_BTN3,
className: 'w-[943px] h-[822px] right-[320px] -bottom-4',
maskConfig: {
className: 'right-[0em] bottom-[100px] w-[520px] h-[400px] rotate-[19deg]',
className:
'right-[0em] bottom-[100px] w-[520px] h-[260px] rotate-[19deg]',
position: 'right-[0em] bottom-[200px]',
size: 'w-[420px] h-[100px]',
transform: 'rotate-[15deg]'
}
}
transform: 'rotate-[15deg]',
},
},
]
// 选择模块
@@ -111,9 +113,9 @@ const selectModule = (module: ModuleType) => {
// 监听外部值变化
watch(
() => props.modelValue,
newValue => {
(newValue) => {
selectedModule.value = newValue
}
},
)
</script>
+231 -67
View File
@@ -1,6 +1,6 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { getBackgroundImage } from '@/utils'
import { computed, ref } from 'vue'
import { getBackgroundImage, getStaticImage } from '@/utils'
import {
useBasicElementsStore,
useFilmStore,
@@ -10,10 +10,11 @@ import {
import {
AnchoredProgressBar,
ImgButton,
type ProgressAnchor,
computeSegmentLayout,
type ProgressSegment,
} from '@/components'
import {
studyModuleAnchors,
studyProgressDisplayConfig,
type StudyProgressModuleKey,
} from './studyProgressConfig'
import {
@@ -26,10 +27,13 @@ defineOptions({
name: 'StudyProgressPopup',
})
type ProductionModuleKey = Exclude<StudyProgressModuleKey, 'basic'>
type StudyProgressItem = {
key: StudyProgressModuleKey
progress: number
anchors: ProgressAnchor[]
segments: ProgressSegment[]
detailSegments?: ProgressSegment[]
}
const visible = defineModel<boolean>({ default: false })
@@ -39,17 +43,25 @@ const vlog = useVlogStore()
const interview = useInterviewStore()
const film = useFilmStore()
const rowPositions = {
basic: { height: 168 },
vlog: { height: 148 },
interview: { height: 146 },
film: { height: 146 },
} as const
const ROW_HEIGHT = {
basic: 168,
vlog: 148,
interview: 146,
film: 146,
} satisfies Record<StudyProgressModuleKey, number>
const PRODUCTION_KEYS = new Set<StudyProgressModuleKey>([
'vlog',
'interview',
'film',
])
const isProduction = (
key: StudyProgressModuleKey,
): key is ProductionModuleKey => PRODUCTION_KEYS.has(key)
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 basicProgress = computed(() =>
getBasicElementsProgress(
@@ -59,12 +71,16 @@ const basicProgress = computed(() =>
),
)
const vlogProgress = computed(() =>
getProductionModuleProgress('vlog', vlog.viewed, vlog.workVideosWatched),
getProductionModuleProgress(
'vlog',
vlog.isRouteViewed,
vlog.workVideosWatched,
),
)
const interviewProgress = computed(() =>
getProductionModuleProgress(
'interview',
interview.viewed,
interview.isRouteViewed,
interview.workVideosWatched,
),
)
@@ -84,32 +100,68 @@ const totalProgress = computed(() =>
filmProgress.value,
]),
)
const buildItem = (
key: StudyProgressModuleKey,
progress: number,
): StudyProgressItem => {
const config = studyProgressDisplayConfig[key]
return {
key,
progress,
segments: config.segments,
detailSegments: config.detailSegments,
}
}
const progressItems = computed<StudyProgressItem[]>(() => [
{
key: 'basic',
progress: basicProgress.value,
anchors: studyModuleAnchors.basic,
},
{
key: 'vlog',
progress: vlogProgress.value,
anchors: studyModuleAnchors.vlog,
},
{
key: 'interview',
progress: interviewProgress.value,
anchors: studyModuleAnchors.interview,
},
{
key: 'film',
progress: filmProgress.value,
anchors: studyModuleAnchors.film,
},
buildItem('basic', basicProgress.value),
buildItem('vlog', vlogProgress.value),
buildItem('interview', interviewProgress.value),
buildItem('film', filmProgress.value),
])
const activeDetailKey = ref<ProductionModuleKey | null>(null)
const closeTimer = ref<number>()
const openDetail = (key: StudyProgressModuleKey) => {
if (!isProduction(key)) return
window.clearTimeout(closeTimer.value)
activeDetailKey.value = key
}
const scheduleClose = () => {
window.clearTimeout(closeTimer.value)
closeTimer.value = window.setTimeout(() => {
activeDetailKey.value = null
}, 120)
}
const toggleDetail = (key: StudyProgressModuleKey) => {
if (!isProduction(key)) return
window.clearTimeout(closeTimer.value)
activeDetailKey.value = activeDetailKey.value === key ? null : key
}
const closeDetail = () => {
window.clearTimeout(closeTimer.value)
activeDetailKey.value = null
}
const handleClose = () => {
closeDetail()
visible.value = false
}
const ZOOM_SEGMENT_ID = 'zoom'
const NODE_IMAGE = 'home/stuy/节点圆圈'
const nodeImageSrc = getStaticImage(NODE_IMAGE)
const detailSegmentLayout = (segments: ProgressSegment[]) =>
computeSegmentLayout(segments, 20, 85)
const zoomPhaseProgress = (progress: number) =>
Math.round(Math.min(100, Math.max(0, ((progress - 20) / 65) * 100)))
</script>
<template>
@@ -125,15 +177,16 @@ const handleClose = () => {
v-if="visible"
class="fixed inset-0 z-[100] bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: getBackgroundImage('home/stuy/学习进度底图') }"
@click="closeDetail"
>
<ImgButton
class="absolute right-0 top-0 z-10"
@click="handleClose"
:image="'components/close'"
@click="handleClose"
/>
<div
class="absolute left-1/2 top-[14%] flex -translate-x-1/2 items-end text-white font-[1000] tracking-widest pr-10"
class="absolute left-1/2 top-[14%] flex -translate-x-1/2 items-end pr-10 text-white font-[1000] tracking-widest"
:style="{ textShadow: totalTextShadow }"
>
<span class="text-[clamp(36px,4.5vw,72px)] leading-none">总进度</span>
@@ -143,40 +196,151 @@ const handleClose = () => {
</div>
<div
class="w-[1580px] mx-auto mt-[215px] flex flex-col pr-16 pl-64 gap-2"
class="mx-auto mt-[215px] flex w-[1580px] flex-col gap-2 pl-[254px] pr-[60px]"
@click.stop
>
<AnchoredProgressBar
<div
v-for="item in progressItems"
:key="item.key"
class="relative w-full"
:style="{ height: `${rowPositions[item.key].height}px` }"
:progress="item.progress"
:anchors="item.anchors"
node-image="home/stuy/节点圆圈"
:label-class="
item.key === 'basic'
? 'text-[clamp(24px,2.2vw,42px)] text-white font-[1000] tracking-wider'
: 'text-[clamp(24px,2.1vw,40px)] text-white font-[1000] tracking-wider'
"
child-label-class="text-[clamp(20px,1.75vw,34px)] text-[#43f3f2] font-[1000] tracking-wider"
percent-class="text-[clamp(22px,2vw,38px)] text-white font-[1000] italic"
label-top="2%"
child-label-top="34%"
bar-top="58%"
bar-height="18%"
marker-top="50%"
marker-height="34%"
marker-width="1%"
child-marker-top="53%"
child-marker-height="28%"
child-marker-width="0.3%"
node-top="41%"
node-height="52%"
percent-top="78%"
:label-style="{ textShadow: labelTextShadow }"
:child-label-style="{ textShadow: childTextShadow }"
:percent-style="{ textShadow: labelTextShadow }"
/>
:style="{ height: `${ROW_HEIGHT[item.key]}px` }"
@mouseleave="scheduleClose"
>
<div
v-for="seg in item.segments"
:key="seg.id"
class="absolute whitespace-nowrap font-[1000] tracking-wider text-white text-[clamp(28px,2.4vw,48px)]"
:class="[
item.key === 'basic' ? '-translate-x-1/2' : '',
item.key !== 'basic' && seg.id === ZOOM_SEGMENT_ID
? 'cursor-pointer transition-transform duration-200 hover:scale-105'
: '',
]"
:style="{
top: item.key === 'basic' ? '9%' : '7%',
left:
item.key === 'basic'
? `${(seg.start + seg.end) / 2}%`
: `${seg.start}%`,
textShadow: labelTextShadow,
}"
@mouseenter="seg.id === ZOOM_SEGMENT_ID && openDetail(item.key)"
@mouseleave="seg.id === ZOOM_SEGMENT_ID && scheduleClose()"
@click.stop="seg.id === ZOOM_SEGMENT_ID && toggleDetail(item.key)"
>
{{ seg.label }}
</div>
<AnchoredProgressBar
class="absolute inset-x-0 top-1/2 h-[22%] -translate-y-1/2"
:progress="item.progress"
:segments="item.segments"
segment-class="bg-white/10"
/>
<img
:src="nodeImageSrc"
class="absolute -translate-x-1/2 transition-all duration-700 ease-out"
:style="{
top: '24%',
height: '52%',
left: `${Math.min(100, Math.max(0, item.progress))}%`,
}"
alt=""
/>
<div
class="absolute -translate-x-1/2 whitespace-nowrap font-[1000] tracking-widest text-white text-[clamp(22px,2vw,38px)] transition-all duration-700 ease-out"
:style="{
top: '78%',
left: `${Math.min(100, Math.max(0, item.progress))}%`,
textShadow: totalTextShadow,
}"
>
{{ Math.round(item.progress) }}%
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 translate-y-2 scale-95"
enter-to-class="opacity-100 translate-y-0 scale-100"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100 translate-y-0 scale-100"
leave-to-class="opacity-0 translate-y-2 scale-95"
>
<div
v-if="activeDetailKey === item.key && item.detailSegments"
class="absolute bottom-full left-[52.5%] z-20 mb-4 w-[860px] -translate-x-1/2"
@mouseenter="openDetail(item.key)"
@mouseleave="scheduleClose"
@click.stop
>
<div
class="relative rounded-[40px] border-[3px] border-[#43f3f2] bg-gradient-to-br from-[#1e57c8]/80 via-[#0f3da8]/80 to-[#0a2a82]/80 px-12 pb-8 pt-7 shadow-[0_0_44px_rgba(67,243,242,0.7),inset_0_0_24px_rgba(67,243,242,0.28)] backdrop-blur"
>
<div
class="pointer-events-none absolute inset-[6px] rounded-[34px] border border-white/25"
></div>
<div
class="mb-7 flex items-baseline justify-between"
:style="{ textShadow: labelTextShadow }"
>
<span
class="text-[clamp(28px,2.3vw,42px)] font-[1000] tracking-wider text-white"
>
制作变焦镜
</span>
<span
class="text-[clamp(22px,1.9vw,34px)] font-[1000] tracking-widest text-white"
:style="{ textShadow: totalTextShadow }"
>
{{ zoomPhaseProgress(item.progress) }}%
</span>
</div>
<div class="relative h-[120px] w-full">
<div
v-for="seg in detailSegmentLayout(item.detailSegments)"
:key="seg.id"
class="absolute -translate-x-1/2 whitespace-nowrap text-[clamp(22px,1.85vw,34px)] font-[1000] tracking-wider text-white"
:style="{
top: '4%',
left: `${seg.center}%`,
textShadow: labelTextShadow,
}"
>
{{ seg.label }}
</div>
<AnchoredProgressBar
class="absolute inset-x-0 top-1/2 h-[28%] -translate-y-1/2"
:min="20"
:max="85"
:progress="Math.min(85, Math.max(20, item.progress))"
:segments="item.detailSegments"
track-class="bg-[#021543]/85 rounded-[6px] shadow-[inset_0_0_14px_rgba(0,0,0,0.55)] ring-1 ring-white/25"
fill-class="bg-gradient-to-r from-[#7df8ff] via-[#43f3f2] to-[#1ed7ff] shadow-[0_0_24px_rgba(67,243,242,0.95)]"
segment-class="bg-white/5"
/>
<img
:src="nodeImageSrc"
class="absolute -translate-x-1/2 transition-all duration-700 ease-out"
:style="{
top: '24%',
height: '52%',
left: `${
((Math.min(85, Math.max(20, item.progress)) - 20) /
65) *
100
}%`,
}"
alt=""
/>
</div>
</div>
</div>
</Transition>
</div>
</div>
</div>
</Transition>
@@ -1,31 +1,41 @@
import type { ProgressAnchor } from '@/components'
import type { ProgressSegment } from '@/components'
export type StudyProgressModuleKey = 'basic' | 'vlog' | 'interview' | 'film'
const productionAnchors: ProgressAnchor[] = [
{ id: 'task', label: '任务取景框', value: 0 },
{
id: 'zoom',
label: '制作变焦镜',
value: 20,
children: [
{ id: 'prepare', label: '前期准备', value: 20 },
{ id: 'shooting', label: '中期拍摄', value: 42 },
{ id: 'editing', label: '后期剪辑', value: 64 }
]
},
{ id: 'practice', label: '实战操作台', value: 85 },
{ id: 'done', label: '', value: 100 }
export type StudyProgressDisplayConfig = {
segments: ProgressSegment[]
detailSegments?: ProgressSegment[]
}
const basicSegments: ProgressSegment[] = [
{ id: 'three-elements', label: '三大基础要素', start: 0, end: 33.33 },
{ id: 'shooting-skills', label: '基础拍摄技巧', start: 33.33, end: 66.66 },
{ id: 'editing-skills', label: '短视频剪辑技巧', start: 66.66, end: 100 },
]
export const studyModuleAnchors: Record<StudyProgressModuleKey, ProgressAnchor[]> = {
basic: [
{ id: 'three-elements', label: '三大基础要素', value: 0 },
{ id: 'shooting-skills', label: '基础拍摄技巧', value: 33.33 },
{ id: 'editing-skills', label: '短视频剪辑技巧', value: 66.66 },
{ id: 'done', label: '', value: 100 }
],
vlog: productionAnchors,
interview: productionAnchors,
film: productionAnchors
const productionSegments: ProgressSegment[] = [
{ id: 'task', label: '任务取景框', start: 0, end: 20 },
{ id: 'zoom', label: '制作变焦镜', start: 20, end: 85 },
{ id: 'practice', label: '实战操作台', start: 85, end: 100 },
]
export const zoomDetailSegments: ProgressSegment[] = [
{ id: 'prepare', label: '前期准备', start: 20, end: 42 },
{ id: 'shooting', label: '中期拍摄', start: 42, end: 64 },
{ id: 'editing', label: '后期剪辑', start: 64, end: 85 },
]
const productionDisplayConfig: StudyProgressDisplayConfig = {
segments: productionSegments,
detailSegments: zoomDetailSegments,
}
export const studyProgressDisplayConfig: Record<
StudyProgressModuleKey,
StudyProgressDisplayConfig
> = {
basic: { segments: basicSegments },
vlog: productionDisplayConfig,
interview: productionDisplayConfig,
film: productionDisplayConfig,
}
+6 -102
View File
@@ -1,102 +1,6 @@
import { moduleStepMap } from '@/config/moduleStepMap'
export type ProductionModuleKey = 'vlog' | 'interview' | 'film'
type StepKey = 1 | 2 | 3
const countViewedRoutes = (
routes: readonly string[],
viewed: Record<string, boolean>,
) => {
if (routes.length === 0) return 0
return routes.filter((routeName) => viewed[routeName]).length
}
const getStepProgress = (
routes: readonly string[],
viewed: Record<string, boolean>,
) => {
if (routes.length === 0) return 0
return countViewedRoutes(routes, viewed) / routes.length
}
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>,
workVideosWatched: Record<string, boolean> = {},
) => {
const stepRoutes = moduleStepMap[moduleKey]
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 (
progress >= 85 &&
step1Progress === 22 &&
step2Progress === 22 &&
step3Progress === 21
) {
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)
}
export {
getBasicElementsProgress,
getOverallStudyProgress,
getProductionModuleProgress,
type ProductionModuleKey,
} from '@/config/studyProgress'
+78 -14
View File
@@ -1,4 +1,4 @@
import { computed, defineComponent, ref } from 'vue'
import { computed, defineComponent, onUnmounted, ref } from 'vue'
import {
getBackgroundImage,
getPublicPath,
@@ -6,7 +6,8 @@ import {
IMAGE_PATHS,
} from '@/utils'
import { ImgButton } from '@/components/Button'
import { GuideSteps, ImagePreview, Video } from '@/components'
import { CourseGuidePopup, CourseFrameworkPopup, GuideSteps, Video } from '@/components'
import Loading from './components/Loading.vue'
import { useScale } from '@/composables/useScale'
import { useBasicElementsStore } from '@/stores/modules/basic-elements'
@@ -89,10 +90,7 @@ export default defineComponent({
const { scale, offsetX, offsetY } = useScale()
// 首次进入的视频播放序列
const introVideos = computed(() => [
getPublicPath('video/campus life.mp4'),
getPublicPath('video/new/xsdh_CapCut_Tutorial.mp4'),
])
const introVideos = computed(() => [getPublicPath('video/start.mp4')])
const introVideoIndex = ref(0)
const showIntroVideo = ref(isFirstVisit)
const currentIntroSrc = computed(
@@ -116,10 +114,64 @@ export default defineComponent({
}
}
// Loading 加载状态
const showLoading = ref(isFirstVisit)
const loadingProgress = ref(0)
let progressTimer: ReturnType<typeof setInterval> | null = null
let loadingFallbackTimer: ReturnType<typeof setTimeout> | null = null
if (isFirstVisit) {
progressTimer = setInterval(() => {
if (loadingProgress.value < 99) {
loadingProgress.value += 1
}
}, 60)
loadingFallbackTimer = setTimeout(() => {
finishLoading()
}, 8000)
}
onUnmounted(() => {
if (progressTimer) clearInterval(progressTimer)
if (loadingFallbackTimer) clearTimeout(loadingFallbackTimer)
})
const finishLoading = () => {
if (!showLoading.value) return
if (progressTimer) {
clearInterval(progressTimer)
progressTimer = null
}
if (loadingFallbackTimer) {
clearTimeout(loadingFallbackTimer)
loadingFallbackTimer = null
}
loadingProgress.value = 100
setTimeout(() => {
showLoading.value = false
}, 200)
}
const handleIntroVideoPlay = () => {
finishLoading()
}
const handleIntroVideoLoaded = () => {
finishLoading()
}
const openMindMap = () => {
ImagePreview({
images: [getStaticImage('maps/dt')],
maskClosable: true,
CourseFrameworkPopup({
getScaleMetrics: () => ({
scale: scale.value,
offsetX: offsetX.value,
offsetY: offsetY.value,
}),
})
}
const openCourseGuide = () => {
CourseGuidePopup({
getScaleMetrics: () => ({
scale: scale.value,
offsetX: offsetX.value,
@@ -142,19 +194,25 @@ export default defineComponent({
/>
</div>
<div class="absolute right-20 bottom-36 kecheng-wrapper">
<div class="absolute right-20 bottom-10 space-y-3">
<ImgButton
class={['kecheng-wrapper']}
image={IMAGE_PATHS.BUTTONS.COURSE_FRAME}
onClick={openMindMap}
/>
</div>
<div class="absolute right-20 bottom-10 zhiying-wrapper">
<ImgButton image={IMAGE_PATHS.BUTTONS.OPERATE_GUIDE} />
<ImgButton
class={['zhiying-wrapper']}
image={IMAGE_PATHS.BUTTONS.OPERATE_GUIDE}
onClick={openCourseGuide}
/>
</div>
{showIntroVideo.value && (
<div class="absolute inset-0 z-[10000] bg-black flex items-center justify-center">
<Loading
visible={showLoading.value}
progress={loadingProgress.value}
/>
<Video
src={currentIntroSrc.value}
autoplay={true}
@@ -162,6 +220,12 @@ export default defineComponent({
controls={true}
volume={1}
fit="contain"
onPlay={handleIntroVideoPlay}
// onLoadedmetadata={handleIntroVideoLoaded}
// onLoadeddata={handleIntroVideoLoaded}
// onCanplay={handleIntroVideoLoaded}
onCanplaythrough={handleIntroVideoLoaded}
onPlayfailed={finishLoading}
onEnded={handleIntroVideoEnded}
/>
</div>
@@ -19,27 +19,39 @@ const adjustmentList = ref([
{ name: '对比度', value: 0, min: -100, max: 100 },
{ name: '饱和度', value: 0, min: -100, max: 100 },
{ name: '曝光', value: 0, min: -100, max: 100 },
{ name: '色调', value: 0, min: 0, max: 100 },
{ name: '色调', value: 0, min: -100, max: 100 },
{ name: '锐化', value: 0, min: 0, max: 100 },
{ name: '模糊', value: 0, min: 0, max: 100 }
{ name: '模糊', value: 0, min: 0, max: 100 },
])
const colorBarList = ref([
{ id: 1, name: '原图', filter: 'none' },
{ id: 2, name: '暖阳', filter: 'saturate(2.5) contrast(2.1)' },
{ id: 3, name: '冷调', filter: 'hue-rotate(180deg) contrast(0.9)' },
{
id: 3,
name: '冷调',
filter: 'brightness(0.96) contrast(0.95) saturate(0.95) temperature(-18)',
},
{ id: 4, name: '复古', filter: 'sepia(0.6) contrast(1.2) brightness(0.9)' },
{ id: 5, name: '黑白', filter: 'grayscale(1)' },
{ id: 6, name: '鲜艳', filter: 'sepia(0.1) saturate(2.5) contrast(2.1) brightness(1)' }
{
id: 6,
name: '鲜艳',
filter: 'sepia(0.1) saturate(2.5) contrast(2.1) brightness(1)',
},
])
const recommendedParamsText =
'亮度 80、对比度 60、饱和度 100、曝光 - 100、色调 0、锐化 100、模糊 0'
// 获取指定参数的值
// 获取指定参数的值
const imageFilterRef = ref<InstanceType<typeof ImageFilter> | null>(null)
// 获取指定参数的值
const getVal = (name: string) => {
const val = adjustmentList.value.find(item => item.name === name)?.value || 0
const val =
adjustmentList.value.find((item) => item.name === name)?.value || 0
return Number(val)
}
@@ -57,12 +69,13 @@ const updateFilter = () => {
hue: 0,
blur: 0,
sharpen: 0,
filter: 'none'
filter: 'none',
})
return
}
const selectedFilter = colorBarList.value.find(v => v.id === selected.value)?.filter || 'none'
const selectedFilter =
colorBarList.value.find((v) => v.id === selected.value)?.filter || 'none'
imageFilterRef.value.setOptions({
brightness: getVal('亮度'),
@@ -72,7 +85,7 @@ const updateFilter = () => {
hue: getVal('色调'),
blur: getVal('模糊'),
sharpen: getVal('锐化'),
filter: selectedFilter
filter: selectedFilter,
})
}
@@ -84,13 +97,14 @@ const changeOriginalImgae = () => {
// Tab 切换处理
const handleTabChange = (tab: 'color' | 'filter') => {
activeTab.value = tab
btnState.value = false
// 切换时重置所有参数
handleReset()
}
// 重置所有参数
const handleReset = () => {
adjustmentList.value.forEach(item => (item.value = 0))
adjustmentList.value.forEach((item) => (item.value = 0))
selected.value = 1
updateFilter()
}
@@ -119,7 +133,7 @@ const handleTryOther = () => {
const handleFinish = () => {
router.push({
name: 'Interview'
name: 'Interview',
})
}
@@ -146,7 +160,7 @@ const changeColor = (item: { id: number; name: string }) => {
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat -z-10"
:style="{
backgroundImage: getBackgroundImage('after-editing/color/底图')
backgroundImage: getBackgroundImage('after-editing/color/底图'),
}"
></div>
@@ -154,9 +168,14 @@ const changeColor = (item: { id: number; name: string }) => {
<!-- 效果图 -->
<div
class="relative w-[1273px] h-[732px] bg-no-repeat bg-cover mt-10 p-2 pb-[26px] pr-[26px]"
:style="{ backgroundImage: getBackgroundImage('after-editing/color/图片框.png') }"
:style="{
backgroundImage: getBackgroundImage('after-editing/color/图片框.png'),
}"
>
<ImageFilter ref="imageFilterRef" :src="getStaticImage('interview/color/访谈调色')" />
<ImageFilter
ref="imageFilterRef"
:src="getStaticImage('interview/color/访谈调色')"
/>
<!-- 开启对比 -->
<ImgButton
@@ -174,7 +193,9 @@ const changeColor = (item: { id: number; name: string }) => {
<div>
<div
class="w-[445px] h-[759px] bg-no-repeat bg-cover p-2 rounded-xl"
:style="{ backgroundImage: getBackgroundImage('after-editing/color/小底板') }"
:style="{
backgroundImage: getBackgroundImage('after-editing/color/小底板'),
}"
>
<!-- tab切换 -->
<header class="flex h-[75px] items-center justify-center space-x-4">
@@ -204,8 +225,14 @@ const changeColor = (item: { id: number; name: string }) => {
v-show="activeTab === 'color'"
class="space-y-6 h-full overflow-y-auto custom-scrollbar pt-4"
>
<div v-for="item in adjustmentList" :key="item.name" class="px-2">
<div class="flex justify-between text-[#648ab2] text-2xl font-bold mb-2">
<div
v-for="item in adjustmentList"
:key="item.name"
class="px-2"
>
<div
class="flex justify-between text-[#648ab2] text-2xl font-bold mb-2"
>
<span>{{ item.name }}</span>
<span>{{ item.value }}</span>
</div>
@@ -232,11 +259,11 @@ const changeColor = (item: { id: number; name: string }) => {
backgroundImage:
selected == item.id
? getBackgroundImage('after-editing/color/滤镜-选中')
: getBackgroundImage('after-editing/color/滤镜-未选中')
: getBackgroundImage('after-editing/color/滤镜-未选中'),
}"
:class="[
'mx-auto w-[373px] h-[62px] text-white text-2xl font-bold flex items-center justify-center cursor-pointer bg-no-repeat bg-contain bg-center transition-all hover:scale-105',
selected !== item.id && 'text-gray-700'
selected !== item.id && 'text-gray-700',
]"
@click="changeColor(item)"
>
@@ -247,38 +274,63 @@ const changeColor = (item: { id: number; name: string }) => {
</div>
<div class="flex justify-around mt-4">
<!-- 重置 -->
<ImgButton image="after-editing/color/重置" @click="handleReset" />
<ImgButton
image="after-editing/color/重置"
@click="handleReset"
/>
<!-- 生成 -->
<ImgButton image="after-editing/color/生成" @click="handleGenerate" />
<ImgButton
image="after-editing/color/生成"
@click="handleGenerate"
/>
</div>
</div>
</div>
<!-- title -->
<img class="absolute top-0 left-0" :src="getStaticImage('after-editing/color/标题-调色实践')" />
<img
class="absolute top-0 left-0"
:src="getStaticImage('after-editing/color/标题-调色实践')"
/>
<!-- 回到顶部按钮 - 最后一页显示 -->
<p class="absolute left-40 bottom-10 flex items-center">
<img class="rotate-0 w-28 mr-4" src="@/assets/svg/deng.svg" />
<img
class="rotate-0 w-28 mr-4"
src="@/assets/svg/deng.svg"
/>
<span class="text-white text-2xl font-bold">
推荐参数:亮度 90、对比度 100、饱和度 100、曝光 - 90、色调 0、锐化 100、模糊 0
推荐参数:{{ recommendedParamsText }}
</span>
</p>
<!-- 保存图片弹窗 -->
<Overlay :visible="showModal" @close="handleModalClose">
<div class="relative w-[1000px] h-[600px] flex items-center justify-center">
<Overlay
:visible="showModal"
@close="handleModalClose"
>
<div
class="relative w-[1000px] h-[600px] flex items-center justify-center"
>
<!-- 截图内容 -->
<div class="bg-white p-2 rounded-md -rotate-6">
<img :src="screenshot" alt="调色结果" />
<img
:src="screenshot"
alt="调色结果"
/>
</div>
<!-- <img :src="screenshot" alt="调色结果" class="max-w-full max-h-full object-contain rotate-2" /> -->
<!-- 操作按钮 -->
<div class="absolute bottom-1/2 -left-1/3 translate-y-1/2">
<ImgButton image="after-editing/color/按钮-试试其他调色" @click="handleTryOther" />
<ImgButton
image="after-editing/color/按钮-试试其他调色"
@click="handleTryOther"
/>
</div>
<div class="absolute bottom-1/2 -right-1/3 translate-y-1/2">
<ImgButton image="after-editing/color/按钮-结束调色实践" @click="handleFinish" />
<ImgButton
image="after-editing/color/按钮-结束调色实践"
@click="handleFinish"
/>
</div>
</div>
</Overlay>
+6 -12
View File
@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { computed, nextTick, ref } from 'vue'
import { computed, ref } from 'vue'
import { onBeforeRouteLeave, useRouter } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue'
import QuestionQuiz, {
@@ -7,7 +7,7 @@ import QuestionQuiz, {
} from '@/components/QuestionQuiz/QuestionQuiz.vue'
import StepNavigation from '@/components/Guide/StepNavigation.vue'
import UnfinishedLearningPrompt from '@/components/UnfinishedLearningPrompt'
import { IMAGE_PATHS, getPublicPath } from '@/utils'
import { IMAGE_PATHS, getPublicPath, getVideoPoster } from '@/utils'
import { Video } from '@/components'
import { useInterviewStore } from '@/stores'
@@ -99,9 +99,7 @@ const handleQuestionsComplete = () => {
}
currentVideoIndex.value++
nextTick(() => {
playing.value = true
})
playing.value = true
}
const playNextVideo = () => {
@@ -121,17 +119,13 @@ const playNextVideo = () => {
}
currentVideoIndex.value++
nextTick(() => {
playing.value = true
})
playing.value = true
}
const handleStepClick = (index: number) => {
showQuiz.value = false
currentVideoIndex.value = index
nextTick(() => {
playing.value = true
})
playing.value = true
}
const handleClose = () => {
@@ -162,6 +156,7 @@ onBeforeRouteLeave((to) => {
<div class="relative w-full h-full overflow-hidden bg-black">
<Video
:src="currentVideo"
:poster="getVideoPoster(currentVideo)"
v-model="playing"
:muted="false"
:volume="1"
@@ -205,7 +200,6 @@ onBeforeRouteLeave((to) => {
<StepNavigation
:visible="showStepNavigation"
:background-image="''"
:steps="navigationSteps"
:current-step-index="currentVideoIndex"
:completed-steps="completedNavigationSteps"
@@ -3,7 +3,7 @@ import { nextTick, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { Video, GuideSteps, GuideStepsItem } from '@/components'
import { IMAGE_PATHS, getPublicPath } from '@/utils'
import { IMAGE_PATHS, getPublicPath, getVideoPoster } from '@/utils'
import { useInterviewStore } from '@/stores/modules/interview'
const router = useRouter()
@@ -26,6 +26,10 @@ const handleComplete = () => {
playing.value = true
})
}
const handleVideoEnded = () => {
interviewStore.markViewed('InterviewExportPublish')
}
</script>
<template>
@@ -33,11 +37,17 @@ const handleComplete = () => {
<Video
class="w-full h-full"
:src="getPublicPath('video/interview/publish/ftmk_jsyrgs_.mp4')"
:poster="
getVideoPoster(
getPublicPath('video/interview/publish/ftmk_jsyrgs_.mp4'),
)
"
v-model="playing"
:muted="false"
:volume="1"
:controls="true"
:hideNativeControls="true"
@ended="handleVideoEnded"
/>
<ImgButton
class="absolute right-0 top-0 z-20"
@@ -3,7 +3,7 @@ import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { Video } from '@/components'
import { IMAGE_PATHS, getPublicPath } from '@/utils'
import { IMAGE_PATHS, getPublicPath, getVideoPoster } from '@/utils'
const router = useRouter()
const playing = ref(false)
@@ -17,7 +17,12 @@ const handleBack = () => {
<div class="relative w-full h-full overflow-hidden bg-black">
<Video
class="w-full h-full"
:src="getPublicPath('video/editing/yingxiao_batch.mp4')"
:src="getPublicPath('video/interview/editing/ftmk_yx_batch.mp4')"
:poster="
getVideoPoster(
getPublicPath('video/interview/editing/ftmk_yx_batch.mp4'),
)
"
v-model="playing"
:muted="false"
:volume="1"
+1 -1
View File
@@ -113,7 +113,7 @@ const router = useRouter()
const interview = useInterviewStore()
const isViewed = (routeName: string) => {
return interview.viewed?.[routeName] === true
return interview.isRouteViewed(routeName)
}
const currentParentValue = computed<number>(() => {
+36 -3
View File
@@ -2,6 +2,8 @@
import { IMAGE_PATHS, getStaticImage } from '@/utils'
import { ImgButton } from '@/components/Button'
import { useRouter } from 'vue-router'
import { useScale } from '@/composables/useScale'
import { ImagePreview } from '@/components'
// import { GuideSteps, GuideStepsItem } from '@/components/Guide'
const router = useRouter()
@@ -9,6 +11,21 @@ const router = useRouter()
const back = () => {
router.back()
}
const { scale, offsetX, offsetY } = useScale()
const handleOpenTaskDialogue = () => {
ImagePreview({
images: [getStaticImage('interview/img/育人故事配音.png')],
startIndex: 0,
maskClosable: true,
getScaleMetrics: () => ({
scale: scale.value,
offsetX: offsetX.value,
offsetY: offsetY.value,
}),
})
}
</script>
<template>
@@ -18,12 +35,28 @@ const back = () => {
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="back"
/>
<img class="absolute inset-0" :src="getStaticImage('img/背景.png')" alt="" />
<img
class="absolute inset-0"
:src="getStaticImage('img/背景.png')"
alt=""
/>
<img class="absolute top-5 z-10" :src="getStaticImage('img/创意构想-标题.png')" alt="" />
<img
class="absolute top-5 z-10"
:src="getStaticImage('img/创意构想-标题.png')"
alt=""
/>
<div class="relative h-full pt-28 overflow-y-scroll">
<img :src="getStaticImage('interview/img/创意构想.png')" alt="" />
<img
:src="getStaticImage('interview/img/创意构想.png')"
alt=""
/>
<!-- 查看任务独白 -->
<button
class="absolute top-[4550px] left-1/2 -translate-x-1/2 w-[270px] h-[80px] ml-10"
@click="handleOpenTaskDialogue"
/>
</div>
<!-- <GuideSteps :visible="true" position-offset-y="85%">
+11 -11
View File
@@ -30,6 +30,14 @@ const showPracticeGuide = computed(
const returnSteps: GuideStep[] = [
{
id: 'return-step1',
content:
'制作变焦镜分为【前期准备】【中期拍摄】【后期剪辑】三个阶段,我们依次来学习!',
audioSrc: getPublicPath('audio/py_2_008.MP3'),
positionOffsetY: '50%',
highlightSelector: '.steps-section',
},
{
id: 'return-step2',
content:
'接下来,我们将进入真实项目流程,体验一条人物访谈作品是如何一步步完成的。',
audioSrc: getPublicPath('audio/py_3_007.MP3'),
@@ -128,16 +136,6 @@ const handleClose = () => {
router.push({ name: 'Action' })
}
watch(
showPracticeGuide,
(val) => {
if (val) {
interview.markGuideShown(practiceGuideKey)
}
},
{ immediate: true },
)
watch(
[visible, currentStep],
([isVisible, step]) => {
@@ -208,7 +206,9 @@ watch(
position-offset-y="50%"
@complete="handlePracticeGuideComplete"
>
<GuideStepsItem>点击【实战操作台】进入实战操作!</GuideStepsItem>
<GuideStepsItem audio-src="audio/py_6_001.MP3">
点击进入实战操作台,开启你的创作实践!
</GuideStepsItem>
</GuideSteps>
<!-- 引导任务 -->
@@ -15,17 +15,18 @@ const steps = [
{
id: 'step1',
content: '先认识 Vlog:点击右侧「1.vlog初识」继续',
highlightSelector: '.img-switch-item--vlog'
highlightSelector: '.img-switch-item--vlog',
},
{
id: 'step2',
content: '这是主要内容区域的边框',
highlightSelector: '.work-border'
}
highlightSelector: '.work-border',
},
]
const hiddenbar = ref(false)
const currentIndex = ref(0)
const selectedMenuIndex = ref(0)
const contentScrollRef = ref<HTMLElement | null>(null)
const equipmentItems = [
{
@@ -34,49 +35,59 @@ const equipmentItems = [
productImages: [
getStaticImage('equipment/相机产品.png'),
getStaticImage('equipment/摄像机产品.png'),
getStaticImage('equipment/微单产品.png')
getStaticImage('equipment/微单产品.png'),
],
title: getStaticImage('equipment/相机标题.png'),
text: getStaticImage('equipment/相机内容.png')
text: getStaticImage('equipment/相机内容.png'),
},
{
id: 'tripod',
name: '三脚架',
productImages: [getStaticImage('equipment/三角架产品.png')],
title: getStaticImage('equipment/三脚架标题.png'),
text: getStaticImage('equipment/三脚架内容.png')
text: getStaticImage('equipment/三脚架内容.png'),
},
{
id: 'light',
name: '灯光',
productImages: [
getStaticImage('equipment/led补光灯.png'),
getStaticImage('equipment/环形灯产品.png')
getStaticImage('equipment/环形灯产品.png'),
],
title: getStaticImage('equipment/灯标题.png'),
text: getStaticImage('equipment/灯内容.png')
}
text: getStaticImage('equipment/灯内容.png'),
},
]
const menuItems = ref(
equipmentItems.map(item => ({ id: item.id, name: item.name, disabled: false }))
equipmentItems.map((item) => ({
id: item.id,
name: item.name,
disabled: false,
})),
)
const currentEquipment = computed(() => equipmentItems[selectedMenuIndex.value])
const currentProductImages = computed(() => currentEquipment.value.productImages)
const currentProductImages = computed(
() => currentEquipment.value.productImages,
)
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) {
console.log(`菜单项 "${item.name}" 已被禁用`)
return
}
selectedMenuIndex.value = index
currentIndex.value = 0
contentScrollRef.value?.scrollTo({ top: 0 })
}
// 下一张图片
@@ -102,9 +113,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 menuItems"
@@ -112,7 +130,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)"
>
@@ -121,7 +139,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>
@@ -130,9 +151,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
@@ -150,38 +177,66 @@ 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="currentProductImages[currentIndex]" alt="" />
<img
:src="currentProductImages[currentIndex]"
alt=""
/>
</div>
<div
class="equipment-rightbutton"
@click="nextPhone"
:style="{
visibility: currentIndex < currentProductImages.length - 1 ? 'visible' : 'hidden'
visibility:
currentIndex < currentProductImages.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="currentEquipment.title" alt="" />
<img
:src="currentEquipment.title"
alt=""
/>
</div>
<div class="plate-content">
<div
class="plate-content"
ref="contentScrollRef"
>
<div class="text">
<img :src="currentEquipment.text" alt="" />
<img
:src="currentEquipment.text"
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,140 +0,0 @@
<script lang="tsx" setup>
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button'
import { ref } from 'vue'
import { ProgressBar } from '@/components'
import { useRouter } from 'vue-router'
const router = useRouter()
const currentStep = ref(2)
const handleComplete = () => {
console.log('完成前置条件')
// 处理完成逻辑
}
const handleStepChange = (step: number) => {
console.log('步骤变化:', step)
// 处理步骤变化逻辑
}
const handleWorkClick = () => {
console.log('任务取景按钮点击')
// 处理任务取景按钮点击逻辑
router.push({ name: 'Work' })
}
const handleClick = () => {
// 跳转设备准备
router.push({ name: 'equipment' })
}
const shooting = () => {
// 跳转实地取景
router.push({ name: 'shooting' })
}
const highlightShow = ref(true)
</script>
<template>
<div class="w-full h-full relative">
<!-- 背景图片-center -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.BG_CENTER) }"
></div>
<!-- 背景图片 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.BG) }"
></div>
<!-- 标题 -->
<div class="absolute top-2 left-1/2 -translate-x-1/2">
<ImgButton :image="IMAGE_PATHS.VLOG.TITLE" />
</div>
<!-- 任务取景按钮 -->
<div
class="absolute left-24 top-24"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.TASK_CAMERA) }"
>
<ImgButton :image="IMAGE_PATHS.VLOG.TASK_BTN" @click="handleWorkClick" />
</div>
<!-- 中期拍摄底板 -->
<div v-if="highlightShow" class="absolute right-90 ml-[135px]" style="z-index: 998">
<img :src="getStaticImage(IMAGE_PATHS.VLOG.Midterm)" />
</div>
<!-- 进度百分比 -->
<div
v-if="highlightShow"
class="absolute left-1/2 top-[12%] -translate-x-1/2 pointer-events-none"
>
<div class="flex">
<ImgButton :image="IMAGE_PATHS.VLOG.Progress" />
<ImgButton :image="IMAGE_PATHS.VLOG.Four" />
<ImgButton :image="IMAGE_PATHS.VLOG.Zero" />
<ImgButton :image="IMAGE_PATHS.VLOG.Percentage" />
</div>
</div>
<!-- 中期准备按钮文字 -->
<div
v-if="highlightShow"
class="absolute left-[21%] top-[9%] -translate-x-1/2"
style="z-index: 998"
>
<ImgButton
:image="IMAGE_PATHS.VLOG.MidtermEquipment"
@click="handleClick"
class="ml-[60px]"
/>
<ImgButton :image="IMAGE_PATHS.VLOG.MidtermFraming" @click="shooting" class="ml-[40px]" />
<ImgButton :image="IMAGE_PATHS.VLOG.MidtermShoot" class="ml-[20px]" />
</div>
<!-- 遮罩层 -->
<div class="Shadow" v-if="highlightShow">
<div>
<img src="" alt="" />
</div>
<img src="@/assets/images/midterm/引导遮罩.png" alt="" />
</div>
<!-- 叉 -->
<div
v-if="highlightShow"
class="absolute right-0 top-0"
style="z-index: 998; cursor: pointer"
@click="highlightShow = false"
>
<img src="@/assets/images/midterm/叉.png" alt="" />
</div>
<!-- 引导任务 -->
<!-- <ImgButton class="absolute right-24 bottom-24" :image="IMAGE_PATHS.VLOG.TASK_PERSON" /> -->
<div class="absolute bottom-0 w-full bright">
<ProgressBar
:current-step="currentStep"
@complete="handleComplete"
@step-change="handleStepChange"
/>
</div>
</div>
</template>
<style lang="less" scopeds>
.Shadow {
width: 100%;
height: 100%;
img {
width: 100%;
height: 100%;
z-index: 997 !important;
position: absolute;
}
}
.bright {
z-index: 999 !important;
}
</style>
@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { computed, nextTick, ref } from 'vue'
import { computed, ref } from 'vue'
import { onBeforeRouteLeave, useRouter } from 'vue-router'
import { ImgButton, Video } from '@/components'
import QuestionQuiz, {
@@ -7,7 +7,7 @@ import QuestionQuiz, {
} 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 { IMAGE_PATHS, getPublicPath, getVideoPoster } from '@/utils'
import { useInterviewStore } from '@/stores'
const router = useRouter()
@@ -16,23 +16,48 @@ 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 getQuestionsForVideo = (index: number): Question[] => {
if (index === 0) {
return [
{
id: 1,
title: '',
description:
'【单选题】《讲述我的育人故事》案例拍摄现场的核心重点是什么?',
options: [
{ id: 'a', label: '搭建访谈现场' },
{ id: 'b', label: '围绕旁白内容采集对应画面' },
{ id: 'c', label: '让人物一直面对镜头讲话' },
],
correctOptionId: 'b',
explanation:
'本片采用人物独白结构,画面任务是配合声音完成叙事,而非传统采访形式。',
},
]
}
if (index === 2) {
return [
{
id: 2,
title: '',
description: '【单选题】下面哪一种拍摄情境,最适合使用滑轨移动镜头?',
options: [
{ id: 'a', label: '人物正对镜头说话,背景简单,镜头静止' },
{
id: 'b',
label: '人物站在窗边,前景有栏杆或墙体,镜头缓慢横向移动',
},
{ id: 'c', label: '人物快速奔跑,摄影师跟着移动拍摄' },
],
correctOptionId: 'b',
explanation:
'滑轨镜头的作用在于稳定、克制地交代空间关系。当前景—人物—背景同时存在时,缓慢的横向移动,会让画面产生层次和叙事感。',
},
]
}
return []
}
const showStepNavigation = ref(false)
@@ -106,44 +131,41 @@ const back = () => {
const handleStepClick = (index: number) => {
showQuiz.value = false
currentVideoIndex.value = index
nextTick(() => {
playing.value = true
})
playing.value = true
}
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()
const quizVideoIndexes = [0, 2]
const playNextVideo = () => {
if (currentVideoIndex.value >= navigationSteps.value.length - 1) {
playing.value = false
return
}
currentVideoIndex.value++
nextTick(() => {
playing.value = true
})
playing.value = true
}
const handleQuestionsComplete = () => {
markCurrentOnsiteVideoWatched()
showQuiz.value = false
playNextVideo()
}
const handleEnded = () => {
if (quizVideoIndexes.includes(currentVideoIndex.value)) {
playing.value = false
questions.value = getQuestionsForVideo(currentVideoIndex.value)
showQuiz.value = true
return
}
markCurrentOnsiteVideoWatched()
playNextVideo()
}
const handleContinueLearning = () => {
@@ -170,6 +192,7 @@ onBeforeRouteLeave((to) => {
<div class="relative w-full h-full overflow-hidden bg-black">
<Video
:src="currentVideo"
:poster="getVideoPoster(currentVideo)"
v-model="playing"
:muted="false"
:volume="1"
@@ -213,7 +236,6 @@ onBeforeRouteLeave((to) => {
<StepNavigation
:visible="showStepNavigation"
:background-image="getStaticImage('work/vlog背景全屏待定')"
:steps="navigationSteps"
:current-step-index="currentVideoIndex"
:completed-steps="completedNavigationSteps"
@@ -4,17 +4,22 @@ import { getBackgroundImage } from '@/utils'
import { ImgButton } from '@/components/Button'
import { Overlay } from '@/components/Overlay'
import { useRouter } from 'vue-router'
import { useInterviewStore } from '@/stores/modules/interview'
import { useViewfinderA } from './useViewfinderA'
import { useCanvasCapture } from '../composables/useCanvasCapture'
import StartOverlay from '../components/StartOverlay.vue'
import LoadingOverlay from '@/components/Loading/LoadingOverlay.vue'
const router = useRouter()
const interviewStore = useInterviewStore()
const start = ref(false)
// 基础逻辑
const back = () => router.back()
const handleFinish = () => router.push({ name: 'InterviewFilmingLocationB' })
const handleFinish = () => {
interviewStore.markViewed('InterviewFilmingLocationA')
router.push({ name: 'InterviewFilmingLocationB' })
}
// 截图功能
const { isLoading, showModal, capturedImageUrl, capture } = useCanvasCapture()
@@ -1,6 +1,6 @@
<script lang="ts" setup>
import { ref } from 'vue'
import { getBackgroundImage } from '@/utils'
import { getBackgroundImage, getPublicPath } from '@/utils'
import { ImgButton } from '@/components/Button'
import { Overlay } from '@/components/Overlay'
import { useViewfinderB } from './useViewfinderB'
@@ -9,9 +9,12 @@ import StartOverlay from '../components/StartOverlay.vue'
import LoadingOverlay from '@/components/Loading/LoadingOverlay.vue'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { useRouter } from 'vue-router'
import { useInterviewStore } from '@/stores/modules/interview'
const router = useRouter()
const interviewStore = useInterviewStore()
const start = ref(false)
const isFirstVisit = !interviewStore.isRouteViewed('InterviewFilmingLocationB')
// 基础逻辑
const back = () => router.back()
@@ -23,6 +26,11 @@ const handleModalClose = () => {
showModal.value = false
}
const handleFinish = (): void => {
showModal.value = false
router.push({ name: 'InterviewShooting' })
}
// 自由取景框模式
const {
viewfinderContainer,
@@ -82,6 +90,7 @@ const captureScene = async () => {
)
},
onComplete: () => {
if (!isFirstVisit) return
// 延迟显示引导
setTimeout(() => {
showGuide.value = true
@@ -95,6 +104,7 @@ const captureScene = async () => {
*/
const handleComplete = () => {
showGuide.value = false
interviewStore.markViewed('InterviewFilmingLocationB')
router.push({
name: 'Interview',
})
@@ -274,10 +284,14 @@ const handleComplete = () => {
/>
</div>
<!-- 操作按钮 -->
<!-- <div class="absolute bottom-1/2 -right-1/3 translate-y-1/2">
<ImgButton image="shooting/按钮-前往下一个取景地" @click="handleFinish" />
</div> -->
<!-- 完成按钮(非首次学习时显示) -->
<button
v-if="!isFirstVisit"
class="absolute -bottom-16 -rotate-6 ml-10 left-1/2 -translate-x-1/2 px-16 py-4 text-3xl font-bold text-white rounded-full bg-[#1b8fd8] shadow-xl hover:bg-[#1597d7] active:scale-95 transition-all"
@click="handleFinish"
>
完成
</button>
</div>
</Overlay>
</div>
@@ -293,8 +307,8 @@ const handleComplete = () => {
positionOffsetY="80%"
@complete="handleComplete"
>
<GuideStepsItem>
实地取景已全部完成,现在跟着我走进现场开始正式的拍摄吧~
<GuideStepsItem :audioSrc="getPublicPath('audio/py_2_022.MP3')">
实地取景已全部完成,现在跟着我走进现场,开始正式的拍摄吧!
</GuideStepsItem>
</GuideSteps>
</div>
@@ -3,9 +3,9 @@ import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { ImgButton } from '@/components/Button'
import { getBackgroundImage, getPublicPath } from '@/utils'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/utils'
import shootingtitle from '@/assets/images/shooting/标题-实地取景.png'
import videoSrc from '/video/xinshou_jiuzhonggoutufa.mp4'
import videoSrc from '/video/new/composition/xsdh_jzgtf_batch.mp4'
import KnowledgeCard from '@/components/KnowledgeCard/InterviewKnowledgeCard.vue'
import CompositionGuide from '@/components/CompositionGuide.vue'
import { useInterviewStore } from '@/stores/modules/interview'
@@ -77,16 +77,32 @@ const back = () => {
/>
</div>
<div class="absolute left-[12%] top-1/2 -translate-y-[calc(50%-70px)]">
<ImgButton
image="interview/shooting/操场-入口.png"
@click="handleClickA"
/>
<div class="relative inline-block">
<ImgButton
image="interview/shooting/操场-入口.png"
@click="handleClickA"
/>
<img
v-if="interviewStore.isRouteViewed('InterviewFilmingLocationA')"
:src="getStaticImage('midterm/完成.png')"
class="absolute -top-8 right-6 w-24 pointer-events-none"
alt="已完成"
/>
</div>
</div>
<div class="absolute right-[12%] top-1/2 -translate-y-[calc(50%-70px)]">
<ImgButton
image="interview/shooting/教学楼-入口.png"
@click="handleClickB"
/>
<div class="relative inline-block">
<ImgButton
image="interview/shooting/教学楼-入口.png"
@click="handleClickB"
/>
<img
v-if="interviewStore.isRouteViewed('InterviewFilmingLocationB')"
:src="getStaticImage('midterm/完成.png')"
class="absolute -top-8 right-14 w-24 pointer-events-none"
alt="已完成"
/>
</div>
</div>
<div class="absolute bottom-4 left-4 btn-1">
<ImgButton
+89 -8
View File
@@ -3,6 +3,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button'
import { StarRating } from '@/components/Rating'
import { GuideSteps, type GuideStep } from '@/components/Guide'
import { useRouter } from 'vue-router'
import BackToTopIcon from '@/assets/images/components/置顶.png'
import { useInterviewStore } from '@/stores/modules/interview'
@@ -20,6 +21,77 @@ const worksScrollIndex = ref<number>(0)
const animatingToPage = ref<number>(0)
const containerRef = ref<HTMLElement | null>(null)
const isAnimating = ref<boolean>(false)
const visible = ref<boolean>(false)
const guideKeyPrefix = 'interview-practice-page'
const pageGuideSteps: Record<number, GuideStep[]> = {
0: [
{
id: 'interview-practice-page-0-2',
content: '这里可以查看任务描述。',
highlightSelector: '.desc-img',
positionOffsetY: '87%',
audioSrc: '/audio/py_6_002.MP3',
},
{
id: 'interview-practice-page-0-3',
content: '这里可以查看任务具体要求。',
highlightSelector: '.task-btn',
positionOffsetY: '50%',
audioSrc: '/audio/py_6_003.MP3',
},
{
id: 'interview-practice-page-0-4',
content: '点击下滑箭头,可查看更多学习与实践内容。',
highlightSelector: '.down-scroll-hint',
audioSrc: '/audio/py_6_005.MP3',
},
],
1: [
{
id: 'interview-practice-page-1-1',
content: '点击【我的创作笔记】,记录自己的创作思路与作品说明。',
highlightSelector: '.my-notes-btn',
audioSrc: '/audio/py_6_004.MP3',
},
],
2: [
{
id: 'interview-practice-page-2-1',
content: '点击【查看】按钮,欣赏学长学姐们的优秀实践作品。',
highlightSelector: '.view-btn',
positionOffsetY: '50%',
audioSrc: '/audio/py_6_006.MP3',
},
{
id: 'interview-practice-page-2-2',
content: '点击【教师点评】按钮,查看老师给出的指导与建议。',
highlightSelector: '.teacher-comment-btn',
positionOffsetY: '50%',
audioSrc: '/audio/py_6_007.MP3',
},
{
id: 'interview-practice-page-2-3',
content: '点击向上按钮,可快速返回实战操作台首页。',
highlightSelector: '.back-to-top-btn',
positionOffsetY: '50%',
audioSrc: '/audio/py_6_009.MP3',
},
],
}
const currentGuideKey = computed(() => `${guideKeyPrefix}-${currentPage.value}`)
const currentGuideSteps = computed(
() => pageGuideSteps[currentPage.value] ?? [],
)
const showPageGuide = (): void => {
if (interviewStore.guideShown[currentGuideKey.value]) return
visible.value = true
}
const handlePageGuideComplete = (): void => {
visible.value = false
interviewStore.markGuideShown(currentGuideKey.value)
}
/** 是否为最后一页 */
const isLastPage = computed<boolean>(() => currentPage.value >= TOTAL_PAGES - 1)
@@ -54,8 +126,7 @@ const worksList = ref<WorkItem[]>([
title: '匠心育人润物无声',
stars: 4,
path: 'video/interview/practice/fangtan_cace1_batch.mp4',
description:
'作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介',
description: '26081 沈佳杰 吴思颖 韩心妍 王梓卉 邱政豪\n25084 张鸿斌',
},
// {
// id: 2,
@@ -125,6 +196,7 @@ const smoothScrollTo = (toPage: number): void => {
} else {
isAnimating.value = false
currentPage.value = toPage
showPageGuide()
}
}
@@ -189,6 +261,7 @@ const saveScrollPage = (): void => {
onMounted(() => {
restoreScrollPage()
showPageGuide()
containerRef.value?.addEventListener('wheel', handleWheel, { passive: false })
})
@@ -210,16 +283,16 @@ onUnmounted(() => {
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
/>
<!-- 实战操作台 -->
<!-- 操作描述 -->
<div class="w-full h-[1080px] bg-[#F5EFE6] relative">
<ImgButton
class="absolute left-40 bottom-12 z-10"
class="task-btn absolute left-40 bottom-12 z-10"
image="vlog/practice/查看任务要求"
@click="scrollToNextPage"
/>
<!-- img -->
<img
class="absolute left-0 top-0 z-10"
class="desc-img absolute left-0 top-0 z-10"
draggable="false"
:src="getStaticImage('interview/practice/访谈')"
alt=""
@@ -237,7 +310,7 @@ onUnmounted(() => {
:style="{ backgroundImage: getBackgroundImage('vlog/practice/背景2') }"
>
<ImgButton
class="absolute right-20 bottom-0 z-99"
class="my-notes-btn absolute right-20 bottom-0 z-99"
image="vlog/practice/我的创作笔记btn"
path="/interview/practice/my-notes"
/>
@@ -313,6 +386,7 @@ onUnmounted(() => {
</p>
<!-- 查看按钮 -->
<ImgButton
:class="work.id === worksList[0]?.id ? 'view-btn' : ''"
image="vlog/practice/查看"
:path="`/interview/practice/video/${encodeURIComponent(work.path)}`"
/>
@@ -356,7 +430,7 @@ onUnmounted(() => {
<!-- 回到顶部按钮 - 最后一页显示 -->
<img
v-show="isLastPage"
class="absolute bottom-10 left-1/2 -translate-x-1/2 cursor-pointer transition-all duration-300 hover:scale-105"
class="back-to-top-btn absolute bottom-10 left-1/2 -translate-x-1/2 cursor-pointer transition-all duration-300 hover:scale-105"
:src="BackToTopIcon"
@click="scrollToTop"
/>
@@ -364,7 +438,7 @@ onUnmounted(() => {
<!-- 老师点评按钮 - 最后一页显示 -->
<ImgButton
v-show="isLastPage"
class="absolute right-2 bottom-10 z-30"
class="teacher-comment-btn absolute right-2 bottom-10 z-30"
image="components/教师点评按钮"
path="/interview/practice/teacher-comment"
/>
@@ -395,6 +469,13 @@ onUnmounted(() => {
/>
</Transition>
</div>
<GuideSteps
v-if="visible"
:steps="currentGuideSteps"
position-offset-y="70%"
@complete="handlePageGuideComplete"
/>
</div>
</template>
@@ -45,17 +45,17 @@ const defaultEvaluationItems: PracticeRatingItem[] = [
{
title: '人物表达',
description: '能清晰介绍人物身份、特点或故事,让观众认识人物',
score: 4,
score: 0,
},
{
title: '素材拍摄',
description: '画面包含必要人物镜头、环境镜头与细节素材,内容较丰富',
score: 2,
score: 0,
},
{
title: '镜头语言',
description: '合理使用移动镜头、特写、跟拍等方式增强表现力',
score: 5,
score: 0,
},
{
title: '剪辑结构',
@@ -65,12 +65,12 @@ const defaultEvaluationItems: PracticeRatingItem[] = [
{
title: '音画关系',
description: '画面与配音/环境音基本匹配,能辅助理解内容',
score: 5,
score: 0,
},
{
title: '综合观感',
description: '作品完整,有真实感与感染力,具备观看吸引力',
score: 5,
score: 0,
},
]
@@ -79,7 +79,11 @@ const evaluationItems = ref<PracticeRatingItem[]>(
)
const handleSave = (): void => {
const content = noteContent.value.trim()
if (!content) return
store.setPracticeNoteContent(noteContent.value)
store.markViewed('InterviewPractice')
showSuccess.value = true
window.clearTimeout(successTimer)
successTimer = window.setTimeout(() => {
@@ -126,6 +130,7 @@ onBeforeUnmount(() => {
<div class="absolute bottom-24 left-1/2 -translate-x-1/2 flex gap-60">
<ImgButton
image="vlog/practice/保存"
:disabled="disabled"
@click="handleSave"
/>
<ImgButton
@@ -139,7 +144,7 @@ onBeforeUnmount(() => {
:visible="showModal"
v-model:items="evaluationItems"
background-image="interview/practice/人物访谈短片创作-作品评分表"
top-tip="评分时要关注6个维度哦~分别是人物表达、素材拍摄、镜头语言、剪辑结构、音画关系、综合观感每个维度满分5分,能打1-5分,记得对照作品表现来打分呀~"
top-tip="评分时要关注6个维度哦!分别是人物表达、素材拍摄、镜头语言、剪辑结构、音画关系、综合观感每个维度满分5分,能打1-5分,记得对照作品表现来打分呀~"
bottom-tip="己评价的实战作品,从封面入口就能直接看到具体评分!打了多少分一目了然,不用再点进详情页翻找啦~"
@save="handleSaveEvaluation"
@close="showModal = false"
@@ -13,7 +13,7 @@ const handleBack = (): void => {
<template>
<div class="relative w-full h-full overflow-x-hidden">
<ImgButton
class="absolute right-0 top-0 z-50"
class="fixed right-0 top-0 z-50"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleBack"
/>
+27 -8
View File
@@ -5,13 +5,21 @@ import { ImgButton } from '@/components/Button'
import PracticeRatingModal, {
type PracticeRatingItem,
} from '@/components/PracticeRatingModal'
import { IMAGE_PATHS, getPublicPath } from '@/utils'
import { IMAGE_PATHS, getPublicPath, getVideoPoster } from '@/utils'
import { useInterviewStore } from '@/stores/modules/interview'
import { computed, ref } from 'vue'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
const router = useRouter()
const store = useInterviewStore()
const guideKey = 'interview-practice-video-rating'
const showGuide = ref(!store.guideShown[guideKey])
const handleRatingGuideComplete = () => {
store.markGuideShown(guideKey)
}
const props = defineProps({
path: {
type: String,
@@ -40,17 +48,17 @@ const defaultEvaluationItems: PracticeRatingItem[] = [
{
title: '人物表达',
description: '能清晰介绍人物身份、特点或故事,让观众认识人物',
score: 4,
score: 0,
},
{
title: '素材拍摄',
description: '画面包含必要人物镜头、环境镜头与细节素材,内容较丰富',
score: 2,
score: 0,
},
{
title: '镜头语言',
description: '合理使用移动镜头、特写、跟拍等方式增强表现力',
score: 5,
score: 0,
},
{
title: '剪辑结构',
@@ -60,12 +68,12 @@ const defaultEvaluationItems: PracticeRatingItem[] = [
{
title: '音画关系',
description: '画面与配音/环境音基本匹配,能辅助理解内容',
score: 5,
score: 0,
},
{
title: '综合观感',
description: '作品完整,有真实感与感染力,具备观看吸引力',
score: 5,
score: 0,
},
]
@@ -97,12 +105,13 @@ const handleBack = (): void => {
@click="handleBack"
/>
<ImgButton
class="absolute right-0 bottom-10 z-50"
class="absolute right-0 top-1/2 -translate-y-1/2 z-50"
image="vlog/practice/评分"
@click="showModal = true"
/>
<Video
:src="getPublicPath(decodeURIComponent(props.path))"
:poster="getVideoPoster(getPublicPath(decodeURIComponent(props.path)))"
:volume="1"
:muted="false"
:controls="true"
@@ -113,10 +122,20 @@ const handleBack = (): void => {
:visible="showModal"
v-model:items="evaluationItems"
background-image="interview/practice/人物访谈短片创作-作品评分表"
top-tip="评分时要关注6个维度哦~分别是人物表达、素材拍摄、镜头语言、剪辑结构、音画关系、综合观感每个维度满分5分,能打1-5分,记得对照作品表现来打分呀~"
top-tip="评分时要关注6个维度哦!分别是人物表达、素材拍摄、镜头语言、剪辑结构、音画关系、综合观感每个维度满分5分,能打1-5分,记得对照作品表现来打分呀~"
bottom-tip="己评价的实战作品,从封面入口就能直接看到具体评分!打了多少分一目了然,不用再点进详情页翻找啦~"
@save="handleSaveEvaluation"
@close="showModal = false"
/>
<GuideSteps
:visible="showGuide"
highlight-selector=".btn-1"
position-offset-y="60%"
@complete="handleRatingGuideComplete"
>
<GuideStepsItem audio-src="/audio/py_6_008.MP3">
点击【评分】,为你喜爱的作品送上五星好评。
</GuideStepsItem>
</GuideSteps>
</div>
</template>
+31 -6
View File
@@ -1,5 +1,10 @@
<script lang="ts" setup>
import { IMAGE_PATHS, getPublicPath, getStaticImage } from '@/utils'
import {
IMAGE_PATHS,
getPublicPath,
getStaticImage,
getVideoPoster,
} from '@/utils'
import { ImgButton } from '@/components/Button'
import { useRouter } from 'vue-router'
// import { GuideSteps, GuideStepsItem } from '@/components/Guide'
@@ -19,7 +24,11 @@ const handleOpenImages = () => {
images: [getStaticImage('interview/我的育人故事_脚本')],
startIndex: 0,
maskClosable: true,
getScaleMetrics: () => ({ scale: scale.value, offsetX: offsetX.value, offsetY: offsetY.value })
getScaleMetrics: () => ({
scale: scale.value,
offsetX: offsetX.value,
offsetY: offsetY.value,
}),
})
}
</script>
@@ -31,16 +40,32 @@ const handleOpenImages = () => {
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="back"
/>
<img class="absolute inset-0" :src="getStaticImage('img/背景.png')" alt="" />
<img
class="absolute inset-0"
:src="getStaticImage('img/背景.png')"
alt=""
/>
<img class="absolute top-5 z-10" :src="getStaticImage('img/脚本编写-标题.png')" alt="" />
<img
class="absolute top-5 z-10"
:src="getStaticImage('img/脚本编写-标题.png')"
alt=""
/>
<div class="relative h-full pt-28 overflow-y-scroll">
<img :src="getStaticImage('interview/img/脚本编写.png')" alt="" />
<img
:src="getStaticImage('interview/img/脚本编写.png')"
alt=""
/>
<div class="absolute top-[250px] left-[8%] w-[960px]">
<Video
:src="getPublicPath('video/editing/color_jianji_batch.mp4')"
:src="getPublicPath('video/film/script-writing/jiaoben.mp4')"
:poster="
getVideoPoster(
getPublicPath('video/film/script-writing/jiaoben.mp4'),
)
"
playsinline
controls
:muted="false"
+52 -37
View File
@@ -1,21 +1,28 @@
<script lang="ts" setup>
import { computed, nextTick, onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { onBeforeRouteLeave, useRouter, useRoute } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue'
import { GuideSteps, type GuideStep } from '@/components/Guide'
import StepNavigation from '@/components/Guide/StepNavigation.vue'
import UnfinishedLearningPrompt from '@/components/UnfinishedLearningPrompt'
import { Video } from '@/components'
import { Video, ImagePreview } from '@/components'
import { useScale } from '@/composables/useScale'
import QuestionQuiz, {
type Question,
} from '@/components/QuestionQuiz/QuestionQuiz.vue'
import { getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
import {
getPublicPath,
getStaticImage,
IMAGE_PATHS,
getVideoPoster,
} from '@/utils'
import { useInterviewStore } from '@/stores'
const router = useRouter()
const route = useRoute()
const interview = useInterviewStore()
const { scale, offsetX, offsetY } = useScale()
// 受控播放开关(用于切换到下一个视频时自动播放)
const playing = ref(false)
@@ -110,17 +117,10 @@ const steps = ref<GuideStep[]>([
])
const introGuideSteps = computed<GuideStep[]>(() => {
if (key.value === 'case') {
return [
{
id: 'case-intro',
audioSrc: getPublicPath('audio/py_3_003.MP3'),
content: '现在,让我们通过一段访谈案例,去看看同龄人心中的职业蓝图吧!',
},
]
}
if (key.value === 'profession') {
if (
key.value === 'profession' &&
!interview.guideShown['work-profession-intro']
) {
return [
{
id: 'profession-intro1',
@@ -142,6 +142,7 @@ const introGuideSteps = computed<GuideStep[]>(() => {
const showIntroGuide = computed(() => introGuideSteps.value.length > 0)
const handleIntroGuideDone = () => {
interview.markGuideShown('work-profession-intro')
playing.value = true
}
@@ -224,7 +225,8 @@ const currentVideo = computed(
() => navigationSteps.value[currentVideoIndex.value].videoUrl,
)
const resolvedSrc = computed(() => {
if (key.value === 'case') return '/video/interview/work/ft_zylt.mp4'
if (key.value === 'case')
return getPublicPath('video/interview/work/ft_zylt.mp4')
if (key.value === 'profession') return currentVideo.value
return ''
})
@@ -233,38 +235,48 @@ const handleVideoChange = () => {
currentVideoIndex.value++
currentNavigationStep.value = currentVideoIndex.value
} else {
if (interview.guideShown['work-profession-complete']) {
return
}
status.value = true
steps.value = [
{
id: 'step1',
audioSrc: getPublicPath('audio/py_2_023.MP3'),
content:
'恭喜你,已完成专业剖析的学习,点击【查看脚本】回顾视频脚本吧!',
'恭喜你!完成了专业剖析的全部内容,点击【返回】按钮查看本案例的【思路提点】。',
},
]
return
}
nextTick(() => {
// 使用受控播放来触发下一个视频自动播放
playing.value = true
})
playing.value = true
}
const handleClose = () => {
if (key.value === 'idea') {
interview.markWorkVideoWatched('idea')
interview.setInterviewUnlockProgress(1)
router.push({
name: 'Interview',
query: { guide: 'return' },
})
return
}
router.back()
interview.requestWorkCompletionCheck()
router.push({ name: 'InterviewWork' })
}
const image = computed(() => route.query.image as string)
const key = computed(() => route.query.key as string)
const ideaImage = computed(() => getStaticImage('interview/work/思路提点全屏'))
const handleIdeaImageClick = () => {
ImagePreview({
images: [ideaImage.value],
maskClosable: true,
getScaleMetrics: () => ({
scale: scale.value,
offsetX: offsetX.value,
offsetY: offsetY.value,
}),
})
}
const handleClick = () => {}
const handlePlay = () => {}
@@ -295,8 +307,10 @@ const onEnded = () => {
const handleComplete = () => {
if (key.value === 'profession') {
interview.markGuideShown('work-profession-complete')
interview.markWorkVideoWatched('profession')
if (!interview.isInterviewUnlock) interview.setIsInterviewUnlock(true)
interview.requestWorkCompletionCheck()
router.push({
name: 'InterviewWork',
})
@@ -315,9 +329,7 @@ const handleStepClick = (index: number) => {
showQuiz.value = false
currentVideoIndex.value = index
currentNavigationStep.value = index
nextTick(() => {
playing.value = true
})
playing.value = true
}
const handleStepNavigationClose = () => {
@@ -336,18 +348,18 @@ const handleConfirmLeave = () => {
router.push(pendingLeavePath.value)
}
onMounted(() => {
if (!showIntroGuide.value) {
playing.value = true
}
})
onBeforeRouteLeave((to) => {
if (allowRouteLeave.value || !hasUnfinishedLearningVideos.value) return true
pendingLeavePath.value = to.fullPath
showUnfinishedPrompt.value = true
return false
})
onMounted(() => {
if (key.value === 'idea') {
status.value = true
}
})
</script>
<template>
@@ -396,6 +408,7 @@ onMounted(() => {
<Video
v-else-if="key === 'case' || key === 'profession'"
:src="resolvedSrc"
:poster="getVideoPoster(resolvedSrc)"
v-model="playing"
:muted="false"
:volume="1"
@@ -421,9 +434,11 @@ onMounted(() => {
<div
v-else-if="key === 'idea'"
:src="image"
class="cursor-pointer"
@click="handleIdeaImageClick"
>
<img
:src="getStaticImage('interview/work/思路提点全屏')"
:src="ideaImage"
alt=""
/>
</div>
@@ -10,6 +10,7 @@
<script setup lang="ts">
import { ImageSwitchList } from '@/components/Switcher'
import { useInterviewStore } from '@/stores'
import { computed } from 'vue'
type WorkTab = 'vlog' | 'case' | 'profession' | 'idea'
@@ -26,41 +27,50 @@ interface Emits {
const props = withDefaults(defineProps<Props>(), {
modelValue: 'case',
itemClass: 'w-[260px] h-[64px] rounded-[18px]'
itemClass: 'w-[260px] h-[64px] rounded-[18px]',
})
const emit = defineEmits<Emits>()
const interview = useInterviewStore()
const getStatusImage = (key: Exclude<WorkTab, 'vlog'>) =>
interview.workVideosWatched[key]
? 'work/任务取景框-完成'
: 'work/任务取景框-未完成'
const innerValue = computed({
get: () => props.modelValue as WorkTab,
set: (val: WorkTab) => emit('update:modelValue', val)
set: (val: WorkTab) => emit('update:modelValue', val),
})
const items = [
const items = computed(() => [
// { key: 'vlog', activeImage: 'work/vlog初识-当前', inactiveImage: 'work/vlog初识-未选中' },
{
key: 'case',
label: '案例赏析',
activeImage: 'work/选中_带三角',
inactiveImage: 'work/未选中_带三角'
inactiveImage: 'work/未选中_带三角',
statusImage: getStatusImage('case'),
},
{
key: 'profession',
label: '专业剖析',
activeImage: 'work/选中_带三角',
inactiveImage: 'work/未选中_带三角'
inactiveImage: 'work/未选中_带三角',
statusImage: getStatusImage('profession'),
},
{
key: 'idea',
label: '思路提点',
activeImage: 'work/选中_带三角',
inactiveImage: 'work/未选中_带三角'
}
]
inactiveImage: 'work/未选中_带三角',
statusImage: getStatusImage('idea'),
},
])
const onChange = (val: string) => emit('change', val as WorkTab)
defineExpose({
items
items,
})
</script>
+58 -6
View File
@@ -1,6 +1,6 @@
<script lang="tsx" name="Work" setup>
defineOptions({ name: 'Work' })
import { ref, computed } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import {
getBackgroundImage,
IMAGE_PATHS,
@@ -9,7 +9,7 @@ import {
} from '@/utils'
import { ImgButton } from '@/components/Button'
import WorkSideSwitcher from './components/WorkSideSwitcher.vue'
import { GuideSteps } from '@/components/Guide'
import { GuideSteps, type GuideStep } from '@/components/Guide'
import { useRouter } from 'vue-router'
import { useScale } from '@/composables/useScale'
import { ImagePreview } from '@/components'
@@ -29,6 +29,43 @@ const workSideSwitcherRef = ref<InstanceType<typeof WorkSideSwitcher>>()
const isGuideInterviewTask = computed(() => interview.isGuideInterviewTask)
const isInterviewUnlock = computed(() => interview.isInterviewUnlock)
const showReturnGuide = ref(false)
const allWorkCompleted = computed(() =>
['case', 'profession', 'idea'].every((key) =>
Boolean(interview.workVideosWatched[key as Exclude<WorkTab, 'vlog'>]),
),
)
const returnSteps: GuideStep[] = [
{
id: 'idea-step1',
audioSrc: getPublicPath('audio/py_3_006.MP3'),
content:
'通过刚才的学习,相信你已经发现,访谈拍摄不仅是在“记录说话”,更是在用镜头完成一次真实的表达。',
},
]
const handleReturnGuideComplete = () => {
showReturnGuide.value = false
interview.setInterviewUnlockProgress(1)
router.replace({ name: 'Interview', query: { guide: 'return' } })
}
const completeWorkFlow = () => {
if (!allWorkCompleted.value || !interview.isGuideInterviewReturn) return
showReturnGuide.value = true
}
watch(allWorkCompleted, (completed, wasCompleted) => {
if (!completed || wasCompleted) return
completeWorkFlow()
})
onMounted(() => {
if (!interview.consumeWorkCompletionCheck()) return
if (!interview.isGuideInterviewReturn) return
completeWorkFlow()
})
//引导步骤
const steps = [
@@ -36,12 +73,17 @@ const steps = [
id: 'step1',
audioSrc: getPublicPath('audio/py_3_001.MP3'),
content: '我们常常会思考,未来自己想成为什么样的人呢?',
highlightSelector: '.img-switch-item--case',
},
{
id: 'step2',
audioSrc: getPublicPath('audio/py_3_002.MP3'),
content: '如果把这份思考勇敢地说出来,被镜头记录下来,你会如何表达呢?',
highlightSelector: '.img-switch-item--case',
},
{
id: 'step3',
audioSrc: getPublicPath('audio/py_3_003.MP3'),
content: '现在,让我们通过一段访谈案例,去看看同龄人心中的职业蓝图吧!',
highlightSelector: '.work-border',
},
]
@@ -176,7 +218,7 @@ function openImages(urls: string[], startIndex = 0) {
</div>
<!-- 左侧选项切换 -->
<div class="absolute right-4 top-56 z-20">
<div class="absolute right-0 top-1/2 -translate-y-1/2 z-20">
<WorkSideSwitcher
ref="workSideSwitcherRef"
v-model="selected"
@@ -186,7 +228,7 @@ function openImages(urls: string[], startIndex = 0) {
</div>
<!-- 任务取景按钮 -->
<ImgButton
<!-- <ImgButton
class="absolute right-14 bottom-10"
:image="
isInterviewUnlock
@@ -194,7 +236,7 @@ function openImages(urls: string[], startIndex = 0) {
: IMAGE_PATHS.WORK.TASK_BTN
"
@click="handleWorkClick"
/>
/> -->
<!-- 引导与高亮 -->
<GuideSteps
@@ -207,6 +249,16 @@ function openImages(urls: string[], startIndex = 0) {
position-offset-y="85%"
/>
<GuideSteps
v-if="showReturnGuide"
:steps="returnSteps"
:highlight-padding="12"
:highlight-radius="20"
@complete="handleReturnGuideComplete"
@skip="handleReturnGuideComplete"
position-offset-y="85%"
/>
<!-- 引导词按钮 -->
<ImgButton
class="absolute w-[120px] bottom-0 right-0 z-10"
+71 -8
View File
@@ -1,15 +1,34 @@
<script setup lang="ts">
import { getStaticImage } from '@/utils'
import type { ResourceItem } from '../resourceData'
const props = defineProps<{
resources: ResourceItem[]
resourceLearnedMap: Record<string, boolean>
}>()
//资源图标 有三种类型video、image、html
const getResourceIcon = (type: string) => {
if (type === 'video') {
return getStaticImage('skill/video.png')
} else if (type === 'image') {
return getStaticImage('skill/image.png')
} else if (type === 'page') {
return getStaticImage('skill/html.png')
}
}
const getUnlearnedBadge = () => getStaticImage('skill/暂未学习.png')
const emit = defineEmits<{
(e: 'resourceClick', resource: ResourceItem): void
}>()
const isResourceLearned = (resource: ResourceItem) =>
props.resourceLearnedMap[resource.id] === true
const handleResourceClick = (resource: ResourceItem) => {
if (!isResourceLearned(resource)) return
emit('resourceClick', resource)
}
</script>
@@ -24,43 +43,87 @@ const handleResourceClick = (resource: ResourceItem) => {
>
暂无资源
</div>
<div v-else class="space-y-3">
<div
v-else
class="space-y-3"
>
<div
v-for="resource in props.resources"
:key="resource.id"
class="group relative overflow-hidden rounded-2xl cursor-pointer transition-all duration-300 hover:scale-[1.02] hover:shadow-2xl"
class="group relative overflow-hidden rounded-2xl transition-[transform,box-shadow] duration-200 ease-out will-change-transform"
:class="
isResourceLearned(resource)
? 'cursor-pointer hover:-translate-y-0.5 hover:shadow-2xl'
: 'cursor-not-allowed'
"
@click="handleResourceClick(resource)"
>
<!-- 背景层 - 使用柔和的半透明渐变 -->
<div
class="absolute inset-0 bg-gradient-to-r from-white/70 via-blue-50/60 to-cyan-50/60 backdrop-blur-md"
:class="!isResourceLearned(resource) ? 'grayscale-[45%]' : ''"
></div>
<div
v-if="!isResourceLearned(resource)"
class="absolute inset-0 z-20 rounded-2xl"
>
<div
class="absolute inset-0 rounded-2xl"
style="background-color: rgba(81, 144, 194, 0.63)"
></div>
<img
:src="getUnlearnedBadge()"
alt="暂未学习"
class="absolute right-5 top-1/2 z-10 w-34 -translate-y-1/2"
/>
</div>
<!-- 左侧装饰条 -->
<div
class="absolute left-0 top-0 bottom-0 w-1.5 bg-gradient-to-b from-cyan-400 via-blue-500 to-purple-500 group-hover:w-2 transition-all duration-300"
class="absolute left-0 top-0 bottom-0 bg-gradient-to-b from-cyan-400 via-blue-500 to-purple-500 transition-all duration-200"
:class="
isResourceLearned(resource)
? 'w-0 opacity-0 group-hover:w-2 group-hover:opacity-100'
: 'w-0 opacity-0'
"
></div>
<!-- 内容层 -->
<div class="relative px-6 py-4">
<div
class="relative px-6 py-4"
:class="!isResourceLearned(resource) ? 'opacity-55' : ''"
>
<!-- 标题 -->
<div
class="font-bold text-3xl text-gray-800 group-hover:text-blue-600 transition-colors duration-300 mb-2"
class="flex items-center gap-2 font-bold text-3xl text-gray-800 group-hover:text-blue-600 transition-colors duration-200 mb-2"
>
<img
:src="getResourceIcon(resource.type)"
alt="icon"
class="w-10"
/>
{{ resource.title }}
</div>
<!-- 描述 -->
<div v-if="resource.description" class="text-2xl text-gray-600 leading-relaxed mb-3">
<div
v-if="resource.description"
class="text-2xl text-gray-600 leading-relaxed mb-3"
>
{{ resource.description }}
</div>
<!-- 标签信息 -->
<div class="flex items-center gap-3 mt-2 text-2xl">
<span class="px-3 py-1 rounded-full bg-blue-100 text-blue-700 font-medium">
<span
class="px-3 py-1 rounded-full bg-blue-100 text-blue-700 font-medium"
>
{{ resource.moduleName }}
</span>
<span class="px-3 py-1 rounded-full bg-purple-100 text-purple-700 font-medium">
<span
class="px-3 py-1 rounded-full bg-purple-100 text-purple-700 font-medium"
>
{{ resource.tagName }}
</span>
<span
+84 -43
View File
@@ -5,7 +5,7 @@ import {
getTagsByModule,
getSubTagsByTag,
getTagResourceCount,
getSubTagIcon
getSubTagIcon,
} from '../resourceData'
import { getStaticImage, getTagColor } from '@/utils'
@@ -29,24 +29,27 @@ const allModules = computed(() => getAllModules())
// 计算要显示的模块
const displayedModules = computed(() => {
if (props.selectedModuleId) {
return allModules.value.filter(m => m.id === props.selectedModuleId)
return allModules.value.filter((m) => m.id === props.selectedModuleId)
}
return allModules.value
})
// 获取所有要显示的标签(按标签名称去重,并按固定顺序排列)
const displayedTags = computed(() => {
const tagMap = new Map<string, { moduleId: string; tagId: string; tagName: string }>()
const tagMap = new Map<
string,
{ moduleId: string; tagId: string; tagName: string }
>()
displayedModules.value.forEach(module => {
displayedModules.value.forEach((module) => {
const tags = getTagsByModule(module.id)
tags.forEach(tag => {
tags.forEach((tag) => {
// 使用标签名称作为唯一键,避免同名标签重复显示
if (!tagMap.has(tag.name)) {
tagMap.set(tag.name, {
moduleId: module.id,
tagId: tag.id,
tagName: tag.name
tagName: tag.name,
})
}
})
@@ -61,12 +64,16 @@ const displayedTags = computed(() => {
'拍摄执行',
'后期制作',
'AI与版权伦理知识',
'思路提点与案例赏析'
'思路提点与案例赏析',
]
// 按照固定顺序返回标签
const orderedTags: Array<{ moduleId: string; tagId: string; tagName: string }> = []
tagOrder.forEach(tagName => {
const orderedTags: Array<{
moduleId: string
tagId: string
tagName: string
}> = []
tagOrder.forEach((tagName) => {
if (tagMap.has(tagName)) {
orderedTags.push(tagMap.get(tagName)!)
}
@@ -99,7 +106,11 @@ const isTagSelected = (moduleId: string, tagId: string) => {
}
// 检查子标签是否选中
const isSubTagSelected = (moduleId: string, tagId: string, subTagId: string) => {
const isSubTagSelected = (
moduleId: string,
tagId: string,
subTagId: string,
) => {
if (props.selectedModuleId) {
// 模块模式:需要模块、标签和子标签都匹配
return (
@@ -121,9 +132,9 @@ const getResourceCount = (moduleId: string, tagId: string, tagName: string) => {
} else {
// 如果显示所有模块,计算所有模块中同名标签的资源总数
let totalCount = 0
displayedModules.value.forEach(module => {
displayedModules.value.forEach((module) => {
const tags = getTagsByModule(module.id)
tags.forEach(tag => {
tags.forEach((tag) => {
if (tag.name === tagName) {
totalCount += getTagResourceCount(module.id, tag.id)
}
@@ -146,7 +157,11 @@ const handleTagClick = (moduleId: string, tagId: string) => {
emit('tagClick', moduleId, tagId)
}
const handleSubTagClick = (moduleId: string, tagId: string, subTagId: string) => {
const handleSubTagClick = (
moduleId: string,
tagId: string,
subTagId: string,
) => {
emit('subTagClick', moduleId, tagId, subTagId)
}
@@ -155,31 +170,28 @@ const getTreeTitle = () => {
if (!props.selectedModuleId) {
return '技能树总览'
}
const module = allModules.value.find(m => m.id === props.selectedModuleId)
const module = allModules.value.find((m) => m.id === props.selectedModuleId)
return module?.name || '技能树总览'
}
// 计算标签节点的位置 - 根据目标效果图1的布局
// 计算标签节点的位置
const getTagPosition = (tagIndex: number) => {
// 定义所有标签的具体位置(根据目标效果图1)
// 标签顺序:视觉素养、摄影基础、剪映基础操作、内容策划、拍摄执行、后期制作、AI与版权伦理知识、思路提点与案例赏析
// 位置池按从上到下、左右交错排列,标签按当前展示顺序依次取坐标。
const positions = [
{ top: 38, left: 33 }, // 0: 视觉素养 - 顶部左侧
{ top: 51, left: 10 }, // 1: 摄影基础 - 顶部右侧偏左
{ top: 51, left: 58 }, // 2: 剪映基础操作 - 顶部右侧
{ top: 32, left: 4 }, // 3: 内容策划 - 中部左侧
{ top: 18, left: 14 }, // 4: 拍摄执行 - 中部右侧(部分在外)
{ top: 5, left: 35 }, // 5: 后期制作 - 中下部左侧
{ top: 22, left: 48 }, // 6: AI与版权伦理知识 - 中下部中间
{ top: 35, left: 64 } // 7: 思路提点与案例赏析 - 中下部右侧
{ top: 5, left: 35 }, // 0: 顶部中间位置
{ top: 18, left: 14 }, // 1: 靠上左侧位置
{ top: 22, left: 48 }, // 2: 靠上右侧位置
{ top: 32, left: 4 }, // 3: 中间左侧位置
{ top: 38, left: 33 }, // 5: 靠下中心位置
{ top: 35, left: 64 }, // 4: 中间右侧位置
{ top: 51, left: 10 }, // 6: 底部左侧位置
{ top: 51, left: 58 }, // 7: 底部右侧位置
]
// 如果标签数量超过预定义位置,使用循环
const position = positions[tagIndex % positions.length]
return {
top: `${position.top}%`,
left: `${position.left}%`
left: `${position.left}%`,
}
}
@@ -188,7 +200,8 @@ const getSubTagPosition = (subTagIndex: number, totalSubTags: number) => {
// 圆形半径(单位:像素)- 根据子标签数量调整半径
// 使用更大的半径确保子标签不会挤在一起
const baseRadius = 160 // 基础半径180px
const radius = totalSubTags <= 3 ? baseRadius : baseRadius + (totalSubTags - 3) * 30
const radius =
totalSubTags <= 3 ? baseRadius : baseRadius + (totalSubTags - 3) * 30
// 计算角度:均匀分布在360度圆周上
// 从顶部开始(-90度),顺时针分布
@@ -200,7 +213,7 @@ const getSubTagPosition = (subTagIndex: number, totalSubTags: number) => {
const offsetY = radius * Math.sin(angle)
return {
transform: `translate(calc(-50% + ${offsetX}px), calc(-50% + ${offsetY + 30}px))` // 让子标签以自身中心定位,并应用偏移
transform: `translate(calc(-50% + ${offsetX}px), calc(-50% + ${offsetY + 30}px))`, // 让子标签以自身中心定位,并应用偏移
}
}
</script>
@@ -217,7 +230,10 @@ const getSubTagPosition = (subTagIndex: number, totalSubTags: number) => {
</div>
<!-- 模块和标签节点 -->
<template v-for="(tag, tagIndex) in displayedTags" :key="`${tag.moduleId}-${tag.tagId}`">
<template
v-for="(tag, tagIndex) in displayedTags"
:key="`${tag.moduleId}-${tag.tagId}`"
>
<!-- 标签节点容器 - 选中的标签提高 z-index 显示在蒙版之上 -->
<div
class="absolute"
@@ -231,7 +247,7 @@ const getSubTagPosition = (subTagIndex: number, totalSubTags: number) => {
isTagSelected(tag.moduleId, tag.tagId) ? 'scale-110 z-30' : '',
props.selectedTagId && !isTagSelected(tag.moduleId, tag.tagId)
? 'opacity-35 brightness-75 saturate-50'
: ''
: '',
]"
>
<!-- 标签主体容器 - 用于精确对齐定位层 -->
@@ -241,13 +257,22 @@ const getSubTagPosition = (subTagIndex: number, totalSubTags: number) => {
:class="[
'relative py-5 cursor-pointer min-w-[240px] max-w-[220px] transition-all hover:scale-105',
'border-4 rounded-full shadow-lg',
getTagColorClass(tag.tagName, isTagSelected(tag.moduleId, tag.tagId)),
isTagSelected(tag.moduleId, tag.tagId) ? 'ring-2 ring-white/80 shadow-2xl' : ''
getTagColorClass(
tag.tagName,
isTagSelected(tag.moduleId, tag.tagId),
),
isTagSelected(tag.moduleId, tag.tagId)
? 'ring-2 ring-white/80 shadow-2xl'
: '',
]"
@click="handleTagClick(tag.moduleId, tag.tagId)"
>
<div class="flex items-center justify-center gap-2 relative z-10">
<span class="text-2xl font-bold truncate">{{ tag.tagName }}</span>
<div
class="flex items-center justify-center gap-2 relative z-10"
>
<span class="text-2xl font-bold truncate">
{{ tag.tagName }}
</span>
<!-- 展开箭头 -->
<!-- <span
v-if="getSubTagsByTag(tag.moduleId, tag.tagId).length > 0"
@@ -261,32 +286,44 @@ const getSubTagPosition = (subTagIndex: number, totalSubTags: number) => {
<!-- 子标签定位层 - 使用 inset-0 与标签主体完全对齐(包括 border 和 padding) -->
<div
v-if="
getSubTagsByTag(tag.moduleId, tag.tagId).length > 0 && isTagExpanded(tag.tagId)
getSubTagsByTag(tag.moduleId, tag.tagId).length > 0 &&
isTagExpanded(tag.tagId)
"
class="absolute inset-0 pointer-events-none"
>
<!-- 子标签列表(围绕父标签圆形分布) -->
<div
v-for="(subTag, subTagIndex) in getSubTagsByTag(tag.moduleId, tag.tagId)"
v-for="(subTag, subTagIndex) in getSubTagsByTag(
tag.moduleId,
tag.tagId,
)"
:key="subTag.id"
class="absolute z-30 pointer-events-auto"
style="top: 50%; left: 50%"
:style="
getSubTagPosition(subTagIndex, getSubTagsByTag(tag.moduleId, tag.tagId).length)
getSubTagPosition(
subTagIndex,
getSubTagsByTag(tag.moduleId, tag.tagId).length,
)
"
>
<div
:class="[
'flex flex-col items-center justify-center cursor-pointer transition-all hover:scale-105'
'flex flex-col items-center justify-center cursor-pointer transition-all hover:scale-105',
// isSubTagSelected(tag.moduleId, tag.tagId, subTag.id)
// ? 'border-orange-400 ring-2 ring-orange-300'
// : 'border-white/50'
]"
@click.stop="handleSubTagClick(tag.moduleId, tag.tagId, subTag.id)"
@click.stop="
handleSubTagClick(tag.moduleId, tag.tagId, subTag.id)
"
>
<!-- 图标-图标资源已经带了文字 -->
<div class="w-30">
<img :src="getStaticImage(getSubTagIcon(subTag.name))" :alt="subTag.name" />
<img
:src="getStaticImage(getSubTagIcon(subTag.name))"
:alt="subTag.name"
/>
</div>
</div>
</div>
@@ -320,7 +357,11 @@ const getSubTagPosition = (subTagIndex: number, totalSubTags: number) => {
</transition> -->
<!-- 右侧标签 -->
<img class="absolute right-4 top-0 z-30" :src="getStaticImage('skill/技能树标题.png')" alt="" />
<img
class="absolute right-4 top-0 z-30"
:src="getStaticImage('skill/技能树标题.png')"
alt=""
/>
</div>
</template>
+183 -66
View File
@@ -1,16 +1,18 @@
<script lang="ts" setup>
defineOptions({
name: 'Skill'
name: 'Skill',
})
import {
getBackgroundImage,
getPublicPath,
getStaticImage,
getTagColor,
IMAGE_PATHS
getVideoPoster,
IMAGE_PATHS,
} from '@/utils'
import { ImgButton } from '@/components/Button'
import { ImagePreview } from '@/components'
import { Video } from '@/components/Video'
import { useRouter } from 'vue-router'
import { ref, computed } from 'vue'
import { useScale } from '@/composables/useScale'
@@ -22,16 +24,23 @@ import {
getSubTagsByTag,
getSubTagsByTagName,
getSubTagIcon,
type ResourceItem
type ResourceItem,
} from './resourceData'
import SearchTags from './components/SearchTags.vue'
import ResourceList from './components/ResourceList.vue'
import SkillTree from './components/SkillTree.vue'
import GuideSteps from '@/components/Guide/GuideSteps.vue'
import { useBasicElementsStore } from '@/stores/modules/basic-elements'
import { useVlogStore } from '@/stores/modules/vlog'
import { useInterviewStore } from '@/stores/modules/interview'
import { useFilmStore } from '@/stores/modules/film'
import { buildResourceLearnedMap } from './utils'
const router = useRouter()
const basicElementsStore = useBasicElementsStore()
const vlogStore = useVlogStore()
const interviewStore = useInterviewStore()
const filmStore = useFilmStore()
const { scale, offsetX, offsetY } = useScale()
// 搜索框文本(保留用于用户输入搜索)
@@ -45,43 +54,68 @@ const currentVideoUrl = ref('')
const guideKey = 'skill-intro'
const showGuide = ref(!basicElementsStore.guideShown[guideKey])
if (showGuide.value) {
basicElementsStore.markGuideShown(guideKey)
}
const guideSteps = ref([
{
id: 'step-1',
title: '',
content:
'点击顶部模块卡片,选择您想要学习的模块(新手导航、vlog发现与记录、访谈聆听与对话、电影创作与表达)',
highlightSelector: '.module-list-container'
'技能树能够帮助你快速检索学习资源,并高效回顾课程内容。接下来,我们一起了解技能树板块的使用方法。',
// highlightSelector: '.module-list-container',
audioSrc: getPublicPath('audio/py_5_001.MP3'),
},
{
id: 'step-2',
title: '',
content:
'在右侧技能树中,点击标签可以查看该技能下的资源。有子标签的标签可以展开查看更多细分技能',
highlightSelector: '.skill-tree-container'
'技能树板块分为三部分,顶部为模块切换按钮,左侧上方为搜索栏,下方为资源列表,右侧技能树上悬挂着课程知识点标签。',
// highlightSelector: '.skill-tree-container',
audioSrc: getPublicPath('audio/py_5_002.MP3'),
},
{
id: 'step-3',
title: '',
content: '左侧会显示当前选中标签下的所有资源,包括视频、图文和页面链接',
highlightSelector: '.resource-list-container'
content: '技能树默认状态下展示本课程全部学习资源。',
highlightSelector: '.module-list-container',
audioSrc: getPublicPath('audio/py_5_003.MP3'),
},
{
id: 'step-4',
title: '',
content: '使用顶部搜索框可以快速搜索资源,支持按标题、模块、标签等关键词搜索',
highlightSelector: '.search-box-container'
content:
'点击任意模块按钮后,左侧资源列表与右侧技能树将同步刷新,仅显示当前所选模块的相关内容。',
highlightSelector: '.module-first-btn',
audioSrc: getPublicPath('audio/py_5_004.MP3'),
},
{
id: 'step-5',
title: '',
content: '点击资源列表中的任意资源,可以查看视频、图文或跳转到相关页面',
highlightSelector: '.resource-list-container'
}
content:
'选择技能树上的任意标签,左侧会对应呈现该标签下所有二级标签及对应资源。',
highlightSelector: '.skill-tree-container',
audioSrc: getPublicPath('audio/py_5_005.MP3'),
},
{
id: 'step-6',
title: '',
content: '点击资源即可回顾微课视频或跳转到图文、交互等相关页面。',
highlightSelector: '.skill-tree-container',
audioSrc: getPublicPath('audio/py_5_006.MP3'),
},
{
id: 'step-7',
title: '',
content:
'在搜索栏输入关键字,可快速搜索资源,支持按标题、模块、标签等字段搜索。',
highlightSelector: '.scrollbar-thin',
audioSrc: getPublicPath('audio/py_5_007.MP3'),
},
{
id: 'step-8',
title: '',
content: '点击【删除】按钮或【清除筛选】按钮可一键清空筛选内容。',
highlightSelector: '.search-box-container',
audioSrc: getPublicPath('audio/py_5_008.MP3'),
},
])
/**
@@ -97,26 +131,26 @@ const moduleList = ref([
id: 'module-1',
name: '新手导航',
selectedImage: 'skill/新手导航-选中.png',
unselectedImage: 'skill/新手导航-未选中.png'
unselectedImage: 'skill/新手导航-未选中.png',
},
{
id: 'module-2',
name: 'vlog发现与记录',
selectedImage: 'skill/vlog-选中.png',
unselectedImage: 'skill/vlog-未选中.png'
unselectedImage: 'skill/vlog-未选中.png',
},
{
id: 'module-3',
name: '访谈聆听与对话',
selectedImage: 'skill/访谈-选中.png',
unselectedImage: 'skill/访谈-未选中.png'
unselectedImage: 'skill/访谈-未选中.png',
},
{
id: 'module-4',
name: '电影创作与表达',
selectedImage: 'skill/电影-选中.png',
unselectedImage: 'skill/电影-未选中.png'
}
unselectedImage: 'skill/电影-未选中.png',
},
])
// 状态管理
@@ -136,14 +170,18 @@ const searchTags = computed<SearchTag[]>(() => {
if (selectedModuleId.value) {
// 模块模式:显示完整路径
const moduleResource = getAllResources().find(r => r.moduleId === selectedModuleId.value)
const moduleResource = getAllResources().find(
(r) => r.moduleId === selectedModuleId.value,
)
if (moduleResource) {
tags.push({ type: 'module', name: moduleResource.moduleName })
}
if (selectedTagId.value) {
const tagResource = getAllResources().find(
r => r.moduleId === selectedModuleId.value && r.tagId === selectedTagId.value
(r) =>
r.moduleId === selectedModuleId.value &&
r.tagId === selectedTagId.value,
)
if (tagResource) {
tags.push({ type: 'tag', name: tagResource.tagName })
@@ -151,10 +189,10 @@ const searchTags = computed<SearchTag[]>(() => {
if (selectedSubTagId.value) {
const subTagResource = getAllResources().find(
r =>
(r) =>
r.moduleId === selectedModuleId.value &&
r.tagId === selectedTagId.value &&
r.subTagId === selectedSubTagId.value
r.subTagId === selectedSubTagId.value,
)
if (subTagResource && subTagResource.subTagName) {
tags.push({ type: 'subTag', name: subTagResource.subTagName })
@@ -164,16 +202,19 @@ const searchTags = computed<SearchTag[]>(() => {
} else {
// 总览模式:只显示标签名称(不显示模块)
if (selectedTagId.value) {
const tagResource = getAllResources().find(r => r.tagId === selectedTagId.value)
const tagResource = getAllResources().find(
(r) => r.tagId === selectedTagId.value,
)
if (tagResource) {
tags.push({ type: 'tag', name: tagResource.tagName })
}
if (selectedSubTagId.value) {
const subTagResource = getAllResources().find(
r =>
(r) =>
r.tagName === currentSelectedTagName.value &&
(r.subTagId === selectedSubTagId.value || r.subTagName === selectedSubTagId.value)
(r.subTagId === selectedSubTagId.value ||
r.subTagName === selectedSubTagId.value),
)
if (subTagResource && subTagResource.subTagName) {
tags.push({ type: 'subTag', name: subTagResource.subTagName })
@@ -186,12 +227,12 @@ const searchTags = computed<SearchTag[]>(() => {
})
const hasActiveFilter = computed(
() => searchTags.value.length > 0 || search.value.trim().length > 0
() => searchTags.value.length > 0 || search.value.trim().length > 0,
)
const currentFilterText = computed(() => {
if (searchTags.value.length > 0) {
return searchTags.value.map(tag => tag.name).join(' / ')
return searchTags.value.map((tag) => tag.name).join(' / ')
}
return search.value.trim()
@@ -202,7 +243,10 @@ const currentSelectedTagName = computed(() => {
return ''
}
return getAllResources().find(r => r.tagId === selectedTagId.value)?.tagName || ''
return (
getAllResources().find((r) => r.tagId === selectedTagId.value)?.tagName ||
''
)
})
const availableSubTags = computed(() => {
@@ -227,11 +271,13 @@ const filteredResources = computed<ResourceItem[]>(() => {
resources = getResourcesBySubTag(
selectedModuleId.value,
selectedTagId.value,
selectedSubTagId.value
selectedSubTagId.value,
)
} else {
resources = getAllResources().filter(
r => r.tagName === currentSelectedTagName.value && r.subTagName === selectedSubTagId.value
(r) =>
r.tagName === currentSelectedTagName.value &&
r.subTagName === selectedSubTagId.value,
)
}
}
@@ -243,7 +289,9 @@ const filteredResources = computed<ResourceItem[]>(() => {
} else {
// 总览模式:返回所有模块中该标签名称的资源
if (currentSelectedTagName.value) {
resources = getAllResources().filter(r => r.tagName === currentSelectedTagName.value)
resources = getAllResources().filter(
(r) => r.tagName === currentSelectedTagName.value,
)
}
}
}
@@ -260,17 +308,48 @@ const filteredResources = computed<ResourceItem[]>(() => {
if (search.value.trim()) {
const searchText = search.value.trim().toLowerCase()
resources = resources.filter(
r =>
(r) =>
r.title.toLowerCase().includes(searchText) ||
r.moduleName.toLowerCase().includes(searchText) ||
r.tagName.toLowerCase().includes(searchText) ||
r.description?.toLowerCase().includes(searchText)
r.description?.toLowerCase().includes(searchText),
)
}
return resources
return resources.sort((a, b) => {
const aLearned = resourceLearnedMap.value[a.id] ? 1 : 0
const bLearned = resourceLearnedMap.value[b.id] ? 1 : 0
return bLearned - aLearned
})
})
const resourceLearnedMap = computed(() =>
buildResourceLearnedMap(getAllResources(), {
basicElements: {
learningVideosWatched: basicElementsStore.learningVideosWatched,
},
vlog: {
viewed: vlogStore.viewed,
professionVideosWatched: vlogStore.professionVideosWatched,
workVideosWatched: vlogStore.workVideosWatched,
isRouteViewed: vlogStore.isRouteViewed,
},
interview: {
viewed: interviewStore.viewed,
professionVideosWatched: interviewStore.professionVideosWatched,
workVideosWatched: interviewStore.workVideosWatched,
isRouteViewed: interviewStore.isRouteViewed,
},
film: {
viewed: filmStore.viewed,
editingVideosWatched: filmStore.editingVideosWatched,
professionVideosWatched: filmStore.professionVideosWatched,
workVideosWatched: filmStore.workVideosWatched,
isRouteViewed: filmStore.isRouteViewed,
},
}),
)
// 处理模块点击
const handleModuleClick = (moduleId: string | null) => {
selectedModuleId.value = moduleId
@@ -301,7 +380,11 @@ const handleTagClick = (moduleId: string, tagId: string) => {
}
// 处理子标签点击
const handleSubTagClick = (moduleId: string, tagId: string, subTagId: string) => {
const handleSubTagClick = (
moduleId: string,
tagId: string,
subTagId: string,
) => {
// 如果点击的是已选中的子标签,则取消选择
if (selectedTagId.value === tagId && selectedSubTagId.value === subTagId) {
selectedSubTagId.value = null
@@ -349,6 +432,10 @@ const handleClose = () => {
// 处理资源点击
const handleResourceClick = (resource: ResourceItem) => {
if (!resourceLearnedMap.value[resource.id]) {
return
}
if (resource.type === 'video' && resource.videoUrl) {
// 视频类型:打开视频播放弹窗
currentVideoUrl.value = getPublicPath(resource.videoUrl) as string
@@ -356,17 +443,21 @@ const handleResourceClick = (resource: ResourceItem) => {
} else if (resource.type === 'page' && resource.routeName) {
// 页面类型:跳转到对应路由
router.push({ name: resource.routeName })
} else if (resource.type === 'image' && resource.images && resource.images.length > 0) {
} else if (
resource.type === 'image' &&
resource.images &&
resource.images.length > 0
) {
// 图文类型:使用 ImagePreview 展示
ImagePreview({
images: resource.images.map(image => getStaticImage(image) as string),
images: resource.images.map((image) => getStaticImage(image) as string),
startIndex: 0,
maskClosable: true,
getScaleMetrics: () => ({
scale: scale.value,
offsetX: offsetX.value,
offsetY: offsetY.value
})
offsetY: offsetY.value,
}),
})
}
}
@@ -379,11 +470,13 @@ const closeVideoModal = () => {
// 处理引导完成
const handleGuideComplete = () => {
basicElementsStore.markGuideShown(guideKey)
showGuide.value = false
}
// 处理引导跳过
const handleGuideSkip = () => {
basicElementsStore.markGuideShown(guideKey)
showGuide.value = false
}
</script>
@@ -393,7 +486,7 @@ const handleGuideSkip = () => {
class="w-full h-full bg-cover bg-center"
:style="{
backgroundImage: getBackgroundImage('skill/背景.png'),
backgroundSize: 'cover'
backgroundSize: 'cover',
}"
>
<!-- 关闭 -->
@@ -408,12 +501,15 @@ const handleGuideSkip = () => {
class="search-box-container absolute left-10 top-10 w-[547px] h-[84px] bg-cover bg-center pl-[130px] pr-[30px] pt-[20px] pb-[21px] flex items-center gap-2 overflow-hidden"
:style="{
backgroundImage: getBackgroundImage('skill/搜索框-已搜索.png'),
backgroundSize: 'cover'
backgroundSize: 'cover',
}"
>
<!-- 搜索标签显示 -->
<div class="flex-shrink-0 overflow-x-auto scrollbar-hide">
<SearchTags :tags="searchTags" @remove="handleRemoveTag" />
<SearchTags
:tags="searchTags"
@remove="handleRemoveTag"
/>
</div>
<!-- 搜索文本输入(可选,用于额外搜索) -->
<input
@@ -431,7 +527,9 @@ const handleGuideSkip = () => {
class="absolute left-10 top-[108px] z-20 flex items-center gap-4 rounded-full border-2 border-white/80 bg-[#1b8fd8]/85 px-6 py-3 text-white shadow-lg backdrop-blur-sm"
>
<span class="text-2xl font-bold">当前查看:{{ currentFilterText }}</span>
<span class="text-xl opacity-90">共 {{ filteredResources.length }} 个资源</span>
<span class="text-xl opacity-90">
共 {{ filteredResources.length }} 个资源
</span>
<button
class="rounded-full bg-white/25 px-4 py-1 text-xl font-bold transition-all hover:bg-white/40"
@click="clearAllFilters"
@@ -446,17 +544,19 @@ const handleGuideSkip = () => {
>
<div class="flex gap-10 -mt-7">
<div
v-for="module in moduleList"
v-for="(module, index) in moduleList"
:key="module.id"
class="relative w-[141px] h-[150px] cursor-pointer"
:class="{ 'opacity-80': selectedModuleId === module.id }"
:class="{ 'opacity-100': selectedModuleId === module.id }"
@click="handleModuleListItemClick(module.id)"
>
<img
class="absolute inset-0"
:class="['absolute inset-0', index === 0 ? 'module-first-btn' : '']"
:src="
getStaticImage(
selectedModuleId === module.id ? module.selectedImage : module.unselectedImage
selectedModuleId === module.id
? module.selectedImage
: module.unselectedImage,
)
"
alt=""
@@ -467,15 +567,20 @@ const handleGuideSkip = () => {
<!-- 资源列表 -->
<div
class="resource-list-container absolute left-10 bottom-20 flex h-[800px] w-[740px] flex-col"
class="resource-list-container absolute left-10 bottom-20 flex h-[815px] w-[740px] flex-col"
>
<div v-if="selectedTagId" class="mb-4">
<div
v-if="selectedTagId"
class="mb-4"
>
<div
class="flex h-[72px] items-center rounded-br-full rounded-tl-full border-4 px-8 shadow-lg"
:class="getTagColor(currentSelectedTagName)"
>
<span class="mr-8 text-5xl leading-none">•</span>
<span class="text-4xl font-bold tracking-[0.25em]">{{ currentSelectedTagName }}</span>
<span class="text-4xl font-bold tracking-[0.25em]">
{{ currentSelectedTagName }}
</span>
</div>
<div
v-if="availableSubTags.length > 0"
@@ -494,11 +599,18 @@ const handleGuideSkip = () => {
:key="subTag.id"
class="flex h-[116px] w-[104px] shrink-0 flex-col items-center justify-center rounded-2xl border-2 transition-all hover:scale-105"
:class="
selectedSubTagId === subTag.id || selectedSubTagId === subTag.name
selectedSubTagId === subTag.id ||
selectedSubTagId === subTag.name
? 'border-white bg-white/35 shadow-lg'
: 'border-white/45 bg-[#1597d7]/45'
"
@click="handleSubTagClick(selectedModuleId || '', selectedTagId || '', subTag.id)"
@click="
handleSubTagClick(
selectedModuleId || '',
selectedTagId || '',
subTag.id,
)
"
>
<img
class="object-contain"
@@ -510,12 +622,18 @@ const handleGuideSkip = () => {
</div>
</div>
<div class="min-h-0 flex-1">
<ResourceList :resources="filteredResources" @resource-click="handleResourceClick" />
<ResourceList
:resources="filteredResources"
:resource-learned-map="resourceLearnedMap"
@resource-click="handleResourceClick"
/>
</div>
</div>
<!-- 技能树-资源树 -->
<div class="skill-tree-container absolute right-0 bottom-20 w-[860px] h-[820px]">
<div
class="skill-tree-container absolute right-0 bottom-20 w-[860px] h-[820px]"
>
<SkillTree
:selected-module-id="selectedModuleId"
:expanded-tags="expandedTags"
@@ -553,15 +671,14 @@ const handleGuideSkip = () => {
</button>
<!-- 视频播放器 -->
<video
<Video
v-if="currentVideoUrl"
:src="currentVideoUrl"
controls
autoplay
class="w-full h-full object-contain"
>
您的浏览器不支持视频播放
</video>
:poster="getVideoPoster(currentVideoUrl)"
:volume="1"
:muted="false"
:autoplay="true"
/>
</div>
</div>
</transition>
File diff suppressed because it is too large. Load diff
-477
View File
@@ -1,477 +0,0 @@
// 资源数据结构定义 - 一维数组格式
export interface ResourceItem {
id: string
title: string
description?: string
// 用于检索的属性
moduleId: string
moduleName: string
tagId: string
tagName: string
subTagId?: string
subTagName?: string
// 资源数量(用于技能树显示)
resourceCount?: number
// 资源类型和链接
type: 'video' | 'page' | 'image' // video: 视频播放, page: 页面跳转, image: 图文展示
videoUrl?: string // 视频URL(type为video时使用)
routeName?: string // 路由名称(type为page时使用)
images?: string[] // 图片URL数组(type为image时使用)
}
// 一维数组格式的资源数据
export const resourceData: ResourceItem[] = [
// 新手导航模块 - 保持原有标签
{
id: 'res-1',
title: '九种构图法',
description: '学习九种基础构图技巧',
moduleId: 'module-1',
moduleName: '新手导航',
tagId: 'tag-1-1',
tagName: '视觉素养',
subTagId: 'subtag-1-1-1',
subTagName: '构图',
type: 'video',
videoUrl: '/video/构图法.mp4'
},
{
id: 'res-2',
title: '光线运用基础',
description: '掌握基础光线运用技巧',
moduleId: 'module-1',
moduleName: '新手导航',
tagId: 'tag-1-1',
tagName: '视觉素养',
subTagId: 'subtag-1-1-2',
subTagName: '光线',
type: 'video',
videoUrl: '/video/光线运用.mp4'
},
{
id: 'res-3',
title: '运镜技巧入门',
description: '学习基础运镜方法',
moduleId: 'module-1',
moduleName: '新手导航',
tagId: 'tag-1-1',
tagName: '视觉素养',
subTagId: 'subtag-1-1-3',
subTagName: '运镜',
type: 'video',
videoUrl: '/video/运镜技巧.mp4'
},
{
id: 'res-4',
title: '摄影基础教程',
description: '摄影基础知识讲解',
moduleId: 'module-1',
moduleName: '新手导航',
tagId: 'tag-1-2',
tagName: '摄影基础',
type: 'image',
images: ['/images/skill/摄影基础1.png', '/images/skill/摄影基础2.png']
},
{
id: 'res-5',
title: '剪映入门',
description: '剪映基础操作教程',
moduleId: 'module-1',
moduleName: '新手导航',
tagId: 'tag-1-3',
tagName: '剪映基础操作',
type: 'video',
videoUrl: '/video/剪映入门.mp4'
},
// vlog发现与记录模块 - 使用统一标签体系
{
id: 'res-6',
title: 'vlog设备准备',
description: 'vlog拍摄设备准备指南',
moduleId: 'module-2',
moduleName: 'vlog发现与记录',
tagId: 'tag-2-1',
tagName: '内容策划',
subTagId: 'subtag-2-1-1',
subTagName: '设备准备',
type: 'video',
videoUrl: '/video/vlog设备准备.mp4'
},
{
id: 'res-7',
title: 'vlog选题构思',
description: 'vlog选题与创意构思',
moduleId: 'module-2',
moduleName: 'vlog发现与记录',
tagId: 'tag-2-1',
tagName: '内容策划',
subTagId: 'subtag-2-1-2',
subTagName: '选题与构思',
type: 'video',
videoUrl: '/video/vlog选题构思.mp4'
},
{
id: 'res-8',
title: 'vlog脚本撰写',
description: '如何撰写vlog脚本',
moduleId: 'module-2',
moduleName: 'vlog发现与记录',
tagId: 'tag-2-1',
tagName: '内容策划',
subTagId: 'subtag-2-1-3',
subTagName: '脚本与分镜制作',
type: 'video',
videoUrl: '/video/vlog脚本撰写.mp4'
},
{
id: 'res-9',
title: 'vlog构图技巧',
description: 'vlog拍摄构图方法',
moduleId: 'module-2',
moduleName: 'vlog发现与记录',
tagId: 'tag-2-2',
tagName: '拍摄执行',
subTagId: 'subtag-2-2-1',
subTagName: '构图',
type: 'video',
videoUrl: '/video/vlog构图技巧.mp4'
},
{
id: 'res-10',
title: 'vlog光线控制',
description: 'vlog拍摄光线运用',
moduleId: 'module-2',
moduleName: 'vlog发现与记录',
tagId: 'tag-2-2',
tagName: '拍摄执行',
subTagId: 'subtag-2-2-2',
subTagName: '光线运用',
type: 'video',
videoUrl: '/video/vlog光线控制.mp4'
},
{
id: 'res-11',
title: 'vlog运镜方法',
description: 'vlog拍摄运镜技巧',
moduleId: 'module-2',
moduleName: 'vlog发现与记录',
tagId: 'tag-2-2',
tagName: '拍摄执行',
subTagId: 'subtag-2-2-3',
subTagName: '运镜技巧',
type: 'video',
videoUrl: '/video/vlog运镜方法.mp4'
},
{
id: 'res-12',
title: 'vlog剪辑技巧',
description: 'vlog视频剪辑方法',
moduleId: 'module-2',
moduleName: 'vlog发现与记录',
tagId: 'tag-2-3',
tagName: '后期制作',
subTagId: 'subtag-2-3-1',
subTagName: '剪辑技巧',
type: 'video',
videoUrl: '/video/vlog剪辑技巧.mp4'
},
{
id: 'res-13',
title: 'vlog调色技巧',
description: 'vlog视频调色方法',
moduleId: 'module-2',
moduleName: 'vlog发现与记录',
tagId: 'tag-2-3',
tagName: '后期制作',
subTagId: 'subtag-2-3-2',
subTagName: '调色',
type: 'video',
videoUrl: '/video/vlog调色技巧.mp4'
},
{
id: 'res-14',
title: 'vlog音效处理',
description: 'vlog音效添加与处理',
moduleId: 'module-2',
moduleName: 'vlog发现与记录',
tagId: 'tag-2-3',
tagName: '后期制作',
subTagId: 'subtag-2-3-3',
subTagName: '音效',
type: 'video',
videoUrl: '/video/vlog音效处理.mp4'
},
{
id: 'res-15',
title: 'vlog版权规范',
description: 'vlog创作版权注意事项',
moduleId: 'module-2',
moduleName: 'vlog发现与记录',
tagId: 'tag-2-4',
tagName: 'AI与版权伦理知识',
type: 'image',
images: [
'/images/skill/版权规范1.png',
'/images/skill/版权规范2.png',
'/images/skill/版权规范3.png'
]
},
{
id: 'res-16',
title: 'vlog案例赏析',
description: '优秀vlog作品分析',
moduleId: 'module-2',
moduleName: 'vlog发现与记录',
tagId: 'tag-2-5',
tagName: '思路提点与案例赏析',
type: 'video',
videoUrl: '/video/vlog案例赏析.mp4'
},
// 访谈聆听与对话模块 - 使用统一标签体系
{
id: 'res-17',
title: '访谈设备准备',
description: '访谈拍摄设备清单',
moduleId: 'module-3',
moduleName: '访谈聆听与对话',
tagId: 'tag-3-1',
tagName: '内容策划',
subTagId: 'subtag-3-1-1',
subTagName: '设备准备',
type: 'video',
videoUrl: '/video/访谈设备准备.mp4'
},
{
id: 'res-18',
title: '访谈选题方法',
description: '如何选择访谈主题',
moduleId: 'module-3',
moduleName: '访谈聆听与对话',
tagId: 'tag-3-1',
tagName: '内容策划',
subTagId: 'subtag-3-1-2',
subTagName: '选题与构思',
type: 'video',
videoUrl: '/video/访谈选题方法.mp4'
},
{
id: 'res-19',
title: '访谈脚本制作',
description: '访谈脚本撰写技巧',
moduleId: 'module-3',
moduleName: '访谈聆听与对话',
tagId: 'tag-3-1',
tagName: '内容策划',
subTagId: 'subtag-3-1-3',
subTagName: '脚本与分镜制作',
type: 'video',
videoUrl: '/video/访谈脚本制作.mp4'
},
{
id: 'res-20',
title: '访谈拍摄技巧',
description: '访谈视频拍摄方法',
moduleId: 'module-3',
moduleName: '访谈聆听与对话',
tagId: 'tag-3-2',
tagName: '拍摄执行',
type: 'video',
videoUrl: '/video/访谈拍摄技巧.mp4'
},
{
id: 'res-21',
title: '访谈后期剪辑',
description: '访谈视频后期制作',
moduleId: 'module-3',
moduleName: '访谈聆听与对话',
tagId: 'tag-3-3',
tagName: '后期制作',
type: 'video',
videoUrl: '/video/访谈后期剪辑.mp4'
},
{
id: 'res-22',
title: '访谈版权规范',
description: '访谈创作版权注意事项',
moduleId: 'module-3',
moduleName: '访谈聆听与对话',
tagId: 'tag-3-4',
tagName: 'AI与版权伦理知识',
type: 'video',
videoUrl: '/video/访谈版权规范.mp4'
},
{
id: 'res-23',
title: '访谈案例赏析',
description: '优秀访谈作品分析',
moduleId: 'module-3',
moduleName: '访谈聆听与对话',
tagId: 'tag-3-5',
tagName: '思路提点与案例赏析',
type: 'video',
videoUrl: '/video/访谈案例赏析.mp4'
},
// 电影创作与表达模块 - 使用统一标签体系
{
id: 'res-24',
title: '电影设备准备',
description: '电影拍摄设备清单',
moduleId: 'module-4',
moduleName: '电影创作与表达',
tagId: 'tag-4-1',
tagName: '内容策划',
subTagId: 'subtag-4-1-1',
subTagName: '设备准备',
type: 'page',
routeName: 'FilmResource'
},
{
id: 'res-25',
title: '电影选题构思',
description: '电影创作选题方法',
moduleId: 'module-4',
moduleName: '电影创作与表达',
tagId: 'tag-4-1',
tagName: '内容策划',
subTagId: 'subtag-4-1-2',
subTagName: '选题与构思',
type: 'video',
videoUrl: '/video/电影选题构思.mp4'
},
{
id: 'res-26',
title: '电影脚本分镜',
description: '电影脚本和分镜制作',
moduleId: 'module-4',
moduleName: '电影创作与表达',
tagId: 'tag-4-1',
tagName: '内容策划',
subTagId: 'subtag-4-1-3',
subTagName: '脚本与分镜制作',
type: 'video',
videoUrl: '/video/电影脚本分镜.mp4'
},
{
id: 'res-27',
title: '电影拍摄执行',
description: '电影拍摄执行技巧',
moduleId: 'module-4',
moduleName: '电影创作与表达',
tagId: 'tag-4-2',
tagName: '拍摄执行',
type: 'video',
videoUrl: '/video/电影拍摄执行.mp4'
},
{
id: 'res-28',
title: '电影后期制作',
description: '电影后期剪辑与特效',
moduleId: 'module-4',
moduleName: '电影创作与表达',
tagId: 'tag-4-3',
tagName: '后期制作',
type: 'video',
videoUrl: '/video/电影后期制作.mp4'
},
{
id: 'res-29',
title: '电影AI工具应用',
description: 'AI工具在电影创作中的应用',
moduleId: 'module-4',
moduleName: '电影创作与表达',
tagId: 'tag-4-4',
tagName: 'AI与版权伦理知识',
type: 'video',
videoUrl: '/video/电影AI工具应用.mp4'
},
{
id: 'res-30',
title: '电影创作思路与案例',
description: '电影创作思路和案例分析',
moduleId: 'module-4',
moduleName: '电影创作与表达',
tagId: 'tag-4-5',
tagName: '思路提点与案例赏析',
type: 'video',
videoUrl: '/video/电影创作思路与案例.mp4'
}
]
// 从一维数组获取所有资源
export function getAllResources(): ResourceItem[] {
return resourceData
}
// 根据模块ID获取资源
export function getResourcesByModule(moduleId: string): ResourceItem[] {
return resourceData.filter(resource => resource.moduleId === moduleId)
}
// 根据资源标签ID获取资源
export function getResourcesByTag(moduleId: string, tagId: string): ResourceItem[] {
return resourceData.filter(resource => resource.moduleId === moduleId && resource.tagId === tagId)
}
// 根据子标签ID获取资源
export function getResourcesBySubTag(
moduleId: string,
tagId: string,
subTagId: string
): ResourceItem[] {
return resourceData.filter(
resource =>
resource.moduleId === moduleId && resource.tagId === tagId && resource.subTagId === subTagId
)
}
// 获取所有模块
export function getAllModules(): Array<{ id: string; name: string }> {
const modules = new Map<string, string>()
resourceData.forEach(resource => {
if (!modules.has(resource.moduleId)) {
modules.set(resource.moduleId, resource.moduleName)
}
})
return Array.from(modules.entries()).map(([id, name]) => ({ id, name }))
}
// 获取模块下的所有标签
export function getTagsByModule(moduleId: string): Array<{ id: string; name: string }> {
const tags = new Map<string, string>()
resourceData
.filter(resource => resource.moduleId === moduleId)
.forEach(resource => {
if (!tags.has(resource.tagId)) {
tags.set(resource.tagId, resource.tagName)
}
})
return Array.from(tags.entries()).map(([id, name]) => ({ id, name }))
}
// 获取标签下的所有子标签
export function getSubTagsByTag(
moduleId: string,
tagId: string
): Array<{ id: string; name: string }> {
const subTags = new Map<string, string>()
resourceData
.filter(
resource => resource.moduleId === moduleId && resource.tagId === tagId && resource.subTagId
)
.forEach(resource => {
if (resource.subTagId && !subTags.has(resource.subTagId)) {
subTags.set(resource.subTagId, resource.subTagName || '')
}
})
return Array.from(subTags.entries()).map(([id, name]) => ({ id, name }))
}
// 获取标签的资源数量
export function getTagResourceCount(moduleId: string, tagId: string): number {
return resourceData.filter(resource => resource.moduleId === moduleId && resource.tagId === tagId)
.length
}
+116
View File
@@ -0,0 +1,116 @@
import type { ResourceItem } from './resourceData'
export type SkillStoreState = {
basicElements: {
learningVideosWatched: Record<string, boolean>
}
vlog: {
viewed: Record<string, boolean>
professionVideosWatched: Record<string, boolean>
workVideosWatched: Record<string, boolean>
isRouteViewed: (name: string) => boolean
}
interview: {
viewed: Record<string, boolean>
professionVideosWatched: Record<string, boolean>
workVideosWatched: Record<string, boolean>
isRouteViewed: (name: string) => boolean
}
film: {
viewed: Record<string, boolean>
editingVideosWatched: Record<string, boolean>
professionVideosWatched: Record<string, boolean>
workVideosWatched: Record<string, boolean>
isRouteViewed: (name: string) => boolean
}
}
const isModuleVideoUnlocked = (
resource: ResourceItem,
stores: SkillStoreState,
): boolean => {
if (!resource.storeKey) return false
switch (resource.moduleId) {
case 'module-1':
return (
stores.basicElements.learningVideosWatched[resource.storeKey] === true
)
case 'module-2':
return (
stores.vlog.professionVideosWatched[resource.storeKey] === true ||
stores.vlog.workVideosWatched[resource.storeKey] === true ||
stores.vlog.isRouteViewed(resource.storeKey)
)
case 'module-3':
return (
stores.interview.professionVideosWatched[resource.storeKey] === true ||
stores.interview.workVideosWatched[resource.storeKey] === true ||
stores.interview.isRouteViewed(resource.storeKey)
)
case 'module-4':
return (
stores.film.editingVideosWatched[resource.storeKey] === true ||
stores.film.professionVideosWatched[resource.storeKey] === true ||
stores.film.workVideosWatched[resource.storeKey] === true ||
stores.film.isRouteViewed(resource.storeKey)
)
default:
return false
}
}
export const isResourceLearned = (
resource: ResourceItem,
stores: SkillStoreState,
): boolean => {
if (resource.type === 'page' && resource.routeName) {
switch (resource.moduleId) {
case 'module-2':
return stores.vlog.isRouteViewed(resource.routeName)
case 'module-3':
return stores.interview.isRouteViewed(resource.routeName)
case 'module-4':
return stores.film.isRouteViewed(resource.routeName)
default:
return false
}
}
if (resource.type === 'video' || resource.type === 'image') {
return isModuleVideoUnlocked(resource, stores)
}
return false
}
export const buildResourceLearnedMap = (
resources: ResourceItem[],
stores: SkillStoreState,
): Record<string, boolean> => {
const result: Record<string, boolean> = {}
for (const resource of resources) {
result[resource.id] = isResourceLearned(resource, stores)
}
return result
}
export const buildTagCountMap = (
resources: ResourceItem[],
stores: SkillStoreState,
): Record<string, { unlocked: number; total: number }> => {
const result: Record<string, { unlocked: number; total: number }> = {}
for (const resource of resources) {
const key = resource.tagName
if (!result[key]) {
result[key] = { unlocked: 0, total: 0 }
}
result[key].total += 1
if (isResourceLearned(resource, stores)) {
result[key].unlocked += 1
}
}
return result
}
-43
View File
@@ -1,43 +0,0 @@
<script lang="ts" setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue'
import { IMAGE_PATHS } from '@/utils'
import six from '/video/xinshou_jiuzhonggoutufa.mp4'
const router = useRouter()
const videoRef = ref()
const handleClose = () => {
router.back()
}
const handlePlay = () => {
if (!videoRef.value) return
videoRef.value.muted = false
videoRef.value.volume = 1
}
</script>
<template>
<div class="w-full h-full relative bg-white/10" @click="handlePlay">
<!-- 返回 -->
<ImgButton
class="absolute right-0 top-0 z-10"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
/>
<video ref="videoRef" :src="six" controls></video>
</div>
</template>
<style scoped>
video {
width: 100%;
height: 100%;
/* 核心:保持视频比例,多余部分被裁剪 */
object-fit: cover;
}
</style>
+85 -26
View File
@@ -5,8 +5,10 @@ import { ImgButton } from '@/components/Button'
import { Overlay } from '@/components/Overlay'
import { ref } from 'vue'
import ImageFilter from '@/components/ImageFilter/ImageFilter.vue'
import { useVlogStore } from '@/stores/modules/vlog'
const router = useRouter()
const vlogStore = useVlogStore()
const selected = ref<number | undefined>(1)
const btnState = ref(false)
@@ -19,27 +21,35 @@ const adjustmentList = ref([
{ name: '对比度', value: 0, min: -100, max: 100 },
{ name: '饱和度', value: 0, min: -100, max: 100 },
{ name: '曝光', value: 0, min: -100, max: 100 },
{ name: '色调', value: 0, min: 0, max: 100 },
{ name: '色调', value: 0, min: -100, max: 100 },
{ name: '锐化', value: 0, min: 0, max: 100 },
{ name: '模糊', value: 0, min: 0, max: 100 }
{ name: '模糊', value: 0, min: 0, max: 100 },
])
const colorBarList = ref([
{ id: 1, name: '原图', filter: 'none' },
{ id: 2, name: '暖阳', filter: 'sepia(0.3) contrast(1.1) brightness(1.1)' },
{ id: 3, name: '冷调', filter: 'hue-rotate(180deg) contrast(0.9)' },
{
id: 3,
name: '冷调',
filter: 'brightness(0.96) contrast(0.95) saturate(0.95) temperature(-18)',
},
{ id: 4, name: '复古', filter: 'sepia(0.6) contrast(1.2) brightness(0.9)' },
{ id: 5, name: '黑白', filter: 'grayscale(1)' },
{ id: 6, name: '鲜艳', filter: 'saturate(1.5) contrast(1.1)' }
{ id: 6, name: '鲜艳', filter: 'saturate(1.5) contrast(1.1)' },
])
const recommendedParamsText =
'亮度 100、对比度 0、饱和度 100、曝光 -100、色调 0、锐化 100、模糊 0'
// 获取指定参数的值
// 获取指定参数的值
const imageFilterRef = ref<InstanceType<typeof ImageFilter> | null>(null)
// 获取指定参数的值
const getVal = (name: string) => {
const val = adjustmentList.value.find(item => item.name === name)?.value || 0
const val =
adjustmentList.value.find((item) => item.name === name)?.value || 0
return Number(val)
}
@@ -57,12 +67,13 @@ const updateFilter = () => {
hue: 0,
blur: 0,
sharpen: 0,
filter: 'none'
filter: 'none',
})
return
}
const selectedFilter = colorBarList.value.find(v => v.id === selected.value)?.filter || 'none'
const selectedFilter =
colorBarList.value.find((v) => v.id === selected.value)?.filter || 'none'
imageFilterRef.value.setOptions({
brightness: getVal('亮度'),
@@ -72,7 +83,7 @@ const updateFilter = () => {
hue: getVal('色调'),
blur: getVal('模糊'),
sharpen: getVal('锐化'),
filter: selectedFilter
filter: selectedFilter,
})
}
@@ -84,13 +95,14 @@ const changeOriginalImgae = () => {
// Tab 切换处理
const handleTabChange = (tab: 'color' | 'filter') => {
activeTab.value = tab
btnState.value = false
// 切换时重置所有参数
handleReset()
}
// 重置所有参数
const handleReset = () => {
adjustmentList.value.forEach(item => (item.value = 0))
adjustmentList.value.forEach((item) => (item.value = 0))
selected.value = 1
updateFilter()
}
@@ -118,8 +130,9 @@ const handleTryOther = () => {
}
const handleFinish = () => {
vlogStore.markViewed('VlogColor')
router.push({
name: 'Vlog'
name: 'Vlog',
})
}
@@ -146,7 +159,7 @@ const changeColor = (item: { id: number; name: string }) => {
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat -z-10"
:style="{
backgroundImage: getBackgroundImage('after-editing/color/底图')
backgroundImage: getBackgroundImage('after-editing/color/底图'),
}"
></div>
@@ -154,9 +167,14 @@ const changeColor = (item: { id: number; name: string }) => {
<!-- 效果图 -->
<div
class="relative w-[1273px] h-[732px] bg-no-repeat bg-cover mt-10 p-2 pb-[26px] pr-[26px]"
:style="{ backgroundImage: getBackgroundImage('after-editing/color/图片框.png') }"
:style="{
backgroundImage: getBackgroundImage('after-editing/color/图片框.png'),
}"
>
<ImageFilter ref="imageFilterRef" :src="getStaticImage('vlog/color/vlog调色')" />
<ImageFilter
ref="imageFilterRef"
:src="getStaticImage('vlog/color/vlog调色')"
/>
<!-- 开启对比 -->
<ImgButton
@@ -174,7 +192,9 @@ const changeColor = (item: { id: number; name: string }) => {
<div>
<div
class="w-[445px] h-[759px] bg-no-repeat bg-cover p-2 rounded-xl"
:style="{ backgroundImage: getBackgroundImage('after-editing/color/小底板') }"
:style="{
backgroundImage: getBackgroundImage('after-editing/color/小底板'),
}"
>
<!-- tab切换 -->
<header class="flex h-[75px] items-center justify-center space-x-4">
@@ -204,8 +224,14 @@ const changeColor = (item: { id: number; name: string }) => {
v-show="activeTab === 'color'"
class="space-y-6 h-full overflow-y-auto custom-scrollbar pt-4"
>
<div v-for="item in adjustmentList" :key="item.name" class="px-2">
<div class="flex justify-between text-[#648ab2] text-2xl font-bold mb-2">
<div
v-for="item in adjustmentList"
:key="item.name"
class="px-2"
>
<div
class="flex justify-between text-[#648ab2] text-2xl font-bold mb-2"
>
<span>{{ item.name }}</span>
<span>{{ item.value }}</span>
</div>
@@ -232,11 +258,11 @@ const changeColor = (item: { id: number; name: string }) => {
backgroundImage:
selected == item.id
? getBackgroundImage('after-editing/color/滤镜-选中')
: getBackgroundImage('after-editing/color/滤镜-未选中')
: getBackgroundImage('after-editing/color/滤镜-未选中'),
}"
:class="[
'mx-auto w-[373px] h-[62px] text-white text-2xl font-bold flex items-center justify-center cursor-pointer bg-no-repeat bg-contain bg-center transition-all hover:scale-105',
selected !== item.id && 'text-gray-700'
selected !== item.id && 'text-gray-700',
]"
@click="changeColor(item)"
>
@@ -247,30 +273,63 @@ const changeColor = (item: { id: number; name: string }) => {
</div>
<div class="flex justify-around mt-4">
<!-- 重置 -->
<ImgButton image="after-editing/color/重置" @click="handleReset" />
<ImgButton
image="after-editing/color/重置"
@click="handleReset"
/>
<!-- 生成 -->
<ImgButton image="after-editing/color/生成" @click="handleGenerate" />
<ImgButton
image="after-editing/color/生成"
@click="handleGenerate"
/>
</div>
</div>
</div>
<!-- title -->
<img class="absolute top-0 left-0" :src="getStaticImage('after-editing/color/标题-调色实践')" />
<img
class="absolute top-0 left-0"
:src="getStaticImage('after-editing/color/标题-调色实践')"
/>
<p class="absolute left-40 bottom-10 flex items-center">
<img
class="rotate-0 w-28 mr-4"
src="@/assets/svg/deng.svg"
/>
<span class="text-white text-2xl font-bold">
推荐参数:{{ recommendedParamsText }}
</span>
</p>
<!-- 保存图片弹窗 -->
<Overlay :visible="showModal" @close="handleModalClose">
<div class="relative w-[1000px] h-[600px] flex items-center justify-center">
<Overlay
:visible="showModal"
@close="handleModalClose"
>
<div
class="relative w-[1000px] h-[600px] flex items-center justify-center"
>
<!-- 截图内容 -->
<div class="bg-white p-2 rounded-md -rotate-6">
<img :src="screenshot" alt="调色结果" />
<img
:src="screenshot"
alt="调色结果"
/>
</div>
<!-- <img :src="screenshot" alt="调色结果" class="max-w-full max-h-full object-contain rotate-2" /> -->
<!-- 操作按钮 -->
<div class="absolute bottom-1/2 -left-1/3 translate-y-1/2">
<ImgButton image="after-editing/color/按钮-试试其他调色" @click="handleTryOther" />
<ImgButton
image="after-editing/color/按钮-试试其他调色"
@click="handleTryOther"
/>
</div>
<div class="absolute bottom-1/2 -right-1/3 translate-y-1/2">
<ImgButton image="after-editing/color/按钮-结束调色实践" @click="handleFinish" />
<ImgButton
image="after-editing/color/按钮-结束调色实践"
@click="handleFinish"
/>
</div>
</div>
</Overlay>
+116 -21
View File
@@ -1,14 +1,67 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { Video } from '@/components'
import { getPublicPath, IMAGE_PATHS } from '@/utils'
import QuestionQuiz, {
type Question,
} from '@/components/QuestionQuiz/QuestionQuiz.vue'
import { Video, GuideSteps, GuideStepsItem } from '@/components'
import { getPublicPath, IMAGE_PATHS, getVideoPoster } from '@/utils'
import { useVlogStore } from '@/stores/modules/vlog'
const router = useRouter()
const vlogStore = useVlogStore()
const COLOR_VIDEO_KEY = 'vlog-color-video'
const PRACTICE_GUIDE_KEY = 'vlog-color-practice-entry'
const videoUrl = getPublicPath('video/vlog/color/Vlogmk_tsybz_batch.mp4')
const videoPlaying = ref(false)
const showQuiz = ref(false)
const showPracticeGuide = ref(false)
const canEnterPractice = computed(
() =>
vlogStore.professionVideosWatched[COLOR_VIDEO_KEY] === true ||
vlogStore.isRouteViewed('VlogColor'),
)
const isPracticeDone = computed(() => vlogStore.isRouteViewed('VlogColor'))
const questions: Question[] = [
{
id: 1,
title: '',
description: '【单选题】调色的主要作用是什么?',
options: [
{ id: 'a', label: '增加镜头数量' },
{ id: 'b', label: '改变视频主题' },
{ id: 'c', label: '统一画面风格与色彩' },
{ id: 'd', label: '替代拍摄构图' },
],
correctOptionId: 'c',
explanation:
'调色能够减少不同场景之间的色彩差异,让整条视频更加统一、协调。',
},
]
const handleEnded = () => {
videoPlaying.value = false
showQuiz.value = true
}
const handleQuestionsComplete = () => {
showQuiz.value = false
vlogStore.markProfessionVideoWatched(COLOR_VIDEO_KEY)
if (!vlogStore.guideShown[PRACTICE_GUIDE_KEY]) {
vlogStore.markGuideShown(PRACTICE_GUIDE_KEY)
showPracticeGuide.value = true
}
}
const handlePracticeGuideComplete = () => {
showPracticeGuide.value = false
}
const handleBack = () => {
router.back()
@@ -16,29 +69,71 @@ const handleBack = () => {
</script>
<template>
<div class="relative w-full h-full flex items-center justify-center overflow-hidden">
<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="videoUrl"
v-model="videoPlaying"
:muted="false"
:volume="1"
:controls="true"
:hideNativeControls="true"
/>
</div>
<div
class="relative w-full h-full flex items-center justify-center overflow-hidden"
>
<ImgButton
class="absolute right-0 top-0 z-20"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleBack"
/>
<ImgButton
class="absolute right-0 bottom-10 z-50"
image="after-editing/color/调色实践"
@click="router.push({ name: 'VlogColorPractice' })"
/>
<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="videoUrl"
:poster="getVideoPoster(videoUrl)"
v-model="videoPlaying"
:muted="false"
:volume="1"
:controls="true"
:hideNativeControls="true"
@ended="handleEnded"
>
<template #overlay>
<QuestionQuiz
v-if="showQuiz"
:show-question-number="false"
:questions="questions"
@complete="handleQuestionsComplete"
/>
</template>
</Video>
</div>
<div
v-if="canEnterPractice"
class="absolute right-0 top-1/2 -translate-y-1/2 z-50 flex flex-col items-center"
>
<ImgButton
image="after-editing/color/调色实践"
@click="router.push({ name: 'VlogColorPractice' })"
class="practice-button"
/>
<span
class="text-3xl font-bold tracking-wider"
:class="
isPracticeDone
? 'text-sky-200 [text-shadow:0_0_10px_rgba(56,189,248,1),0_0_22px_rgba(56,189,248,0.85),0_0_40px_rgba(56,189,248,0.6)]'
: 'text-red-200 [text-shadow:0_0_10px_rgba(248,113,113,1),0_0_22px_rgba(248,113,113,0.85),0_0_40px_rgba(248,113,113,0.6)]'
"
>
{{ isPracticeDone ? '已完成' : '未完成' }}
</span>
</div>
<GuideSteps
v-if="showPracticeGuide"
highlight-selector=".practice-button"
position-offset-y="80%"
@complete="handlePracticeGuideComplete"
@skip="handlePracticeGuideComplete"
>
<GuideStepsItem :audio-src="getPublicPath('audio/py_2_024.MP3')">
点击【调色实践】,一起给案例中的图片调个色吧!
</GuideStepsItem>
</GuideSteps>
</div>
</template>
+83 -35
View File
@@ -5,7 +5,10 @@ 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 QuestionQuiz, {
type Question,
} from '@/components/QuestionQuiz/QuestionQuiz.vue'
import { getPublicPath, IMAGE_PATHS, getVideoPoster } from '@/utils'
import { Video } from '@/components'
import { useVlogStore } from '@/stores/modules/vlog'
@@ -51,12 +54,34 @@ const navigationSteps = ref([
const currentVideoIndex = ref(0)
const playing = ref(false)
const showQuiz = ref(false)
const questions = ref<Question[]>([])
const showStepNavigation = ref(false)
const showUnfinishedPrompt = ref(false)
const pendingLeavePath = ref<string | null>(null)
const allowRouteLeave = ref(false)
const showEndingGuide = ref(false)
const getQuestionsForVideo = (): Question[] => [
{
id: 1,
title: '',
description: '【多选题】以下哪些内容属于视频中的声音组成部分?',
options: [
{ id: 'a', label: '主持人配音' },
{ id: 'b', label: '环境声音' },
{ id: 'c', label: '背景音乐' },
{ id: 'd', label: '转场特效' },
{ id: 'e', label: '现场动作音效' },
],
correctOptionId: '',
correctOptionIds: ['a', 'b', 'c', 'e'],
isMultiple: true,
explanation:
'完整的视频声音,不只是音乐,还包括人声、环境声与动作细节声音。',
},
]
const currentVideo = computed(
() => navigationSteps.value[currentVideoIndex.value].videoUrl,
)
@@ -77,8 +102,6 @@ const markCurrentVideoWatched = () => {
}
const playNextVideo = () => {
markCurrentVideoWatched()
if (currentVideoIndex.value >= navigationSteps.value.length - 1) {
playing.value = false
showEndingGuide.value = true
@@ -86,17 +109,31 @@ const playNextVideo = () => {
}
currentVideoIndex.value++
nextTick(() => {
playing.value = true
})
playing.value = true
}
const handleEnded = () => {
if (currentVideoIndex.value === 3) {
playing.value = false
questions.value = getQuestionsForVideo()
showQuiz.value = true
return
}
markCurrentVideoWatched()
playNextVideo()
}
const handleQuestionsComplete = () => {
markCurrentVideoWatched()
showQuiz.value = false
playNextVideo()
}
const handleStepClick = (index: number) => {
showQuiz.value = false
currentVideoIndex.value = index
// showStepNavigation.value = false
nextTick(() => {
playing.value = true
})
playing.value = true
}
const handleClose = () => {
@@ -137,38 +174,49 @@ onBeforeRouteLeave((to) => {
<div class="relative w-full h-full overflow-hidden bg-black">
<Video
:src="currentVideo"
:poster="getVideoPoster(currentVideo)"
v-model="playing"
:muted="false"
:volume="1"
@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"
:controls="true"
:hideNativeControls="true"
@ended="handleEnded"
>
<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 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 pointer-events-auto"
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"
@@ -3,7 +3,7 @@ import { nextTick, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { Video, GuideSteps, GuideStepsItem } from '@/components'
import { getPublicPath, IMAGE_PATHS } from '@/utils'
import { getPublicPath, IMAGE_PATHS, getVideoPoster } from '@/utils'
import { useVlogStore } from '@/stores/modules/vlog'
const router = useRouter()
@@ -28,19 +28,29 @@ const handleComplete = () => {
videoPlaying.value = true
})
}
const handleVideoEnded = () => {
vlogStore.markViewed('VlogExportPublish')
}
</script>
<template>
<div class="relative w-full h-full flex items-center justify-center overflow-hidden">
<div class="w-full h-full bg-black/40 backdrop-blur-md rounded-3xl border border-white/20">
<div
class="relative w-full h-full flex items-center justify-center overflow-hidden"
>
<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="videoUrl"
:poster="getVideoPoster(videoUrl)"
v-model="videoPlaying"
:muted="false"
:volume="1"
:controls="true"
:hideNativeControls="true"
@ended="handleVideoEnded"
/>
</div>
@@ -50,7 +60,11 @@ const handleComplete = () => {
@click="handleBack"
/>
<GuideSteps v-if="showGuide" position-offset-y="85%" @complete="handleComplete">
<GuideSteps
v-if="showGuide"
position-offset-y="85%"
@complete="handleComplete"
>
<GuideStepsItem :audio-src="getPublicPath('audio/py_2_021.MP3')">
最后一步,就是将作品正式输出。一起看看最终效果吧!
</GuideStepsItem>
+1 -1
View File
@@ -107,7 +107,7 @@ const router = useRouter()
const vlog = useVlogStore()
const isViewed = (routeName: string) => {
return vlog.viewed?.[routeName] === true
return vlog.isRouteViewed(routeName)
}
const currentParentValue = computed<number>(() => {
+6 -12
View File
@@ -49,8 +49,10 @@ const returnSteps: GuideStep[] = [
{
id: 'return-step3',
content:
'在记录社区志愿活动的过程中,学习 Vlog 从前期策划、中期拍摄到后期剪辑的完整流程,同时传递热心助人、服务社会的价值力量。',
'制作变焦镜分为【前期准备】【中期拍摄】【后期剪辑】三个阶段,我们依次来学习!',
audioSrc: getPublicPath('audio/py_2_008.MP3'),
positionOffsetY: '50%',
highlightSelector: '.steps-section',
},
]
@@ -165,16 +167,6 @@ const handleClose = () => {
router.push({ name: 'Action' })
}
watch(
showPracticeGuide,
(val) => {
if (val) {
vlog.markGuideShown(practiceGuideKey)
}
},
{ immediate: true },
)
watch(
[visible, currentStep],
([isVisible, step]) => {
@@ -247,7 +239,9 @@ watch(
position-offset-y="50%"
@complete="vlog.markGuideShown(practiceGuideKey)"
>
<GuideStepsItem>点击【实战操作台】进入实战操作!</GuideStepsItem>
<GuideStepsItem audio-src="audio/py_6_001.MP3">
点击进入实战操作台,开启你的创作实践!
</GuideStepsItem>
</GuideSteps>
<!-- 引导人物 -->
@@ -9,8 +9,14 @@ import { GuideSteps } from '@/components/Guide'
import { onBeforeUnmount, ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { useVlogStore } from '@/stores'
//最后一个设备看到的停留时间
const lastDeviceStayTime = 5000
const router = useRouter()
const vlogStore = useVlogStore()
const equipmentGuideKey = 'vlog-midterm-equipment-finish'
const back = () => {
// 跳转设备准备
@@ -26,12 +32,13 @@ const steps = ref([
])
const showGuide = ref(false)
const guideTriggered = ref(false)
const guideTriggered = ref(vlogStore.guideShown[equipmentGuideKey] === true)
const guideTimer = ref<ReturnType<typeof setTimeout> | null>(null)
const viewedMenuKeys = ref<Set<string>>(new Set())
const hiddenbar = ref(false)
const currentIndex = ref(0) // 当前显示的图片索引
const selectedMenuIndex = ref(0) // 当前选中的菜单项索引
const contentScrollRef = ref<HTMLElement | null>(null)
type EquipmentCategoryId =
| 'phone'
@@ -56,14 +63,9 @@ const categories = ref<EquipmentCategory[]>([
id: 'phone',
name: '手机',
disabled: false,
titleImg: getStaticImage('equipment/标题-苹果.png'),
contentImg: getStaticImage('equipment/内容-苹果.png'),
titleImg: getStaticImage('equipment/标题-安卓小米.png'),
contentImg: getStaticImage('equipment/内容-安卓小米.png'),
products: [
{
img: getStaticImage('equipment/设备-手机1.png'),
titleImg: getStaticImage('equipment/标题-苹果.png'),
contentImg: getStaticImage('equipment/内容-苹果.png'),
},
{
img: getStaticImage('equipment/设备-手机2.png'),
titleImg: getStaticImage('equipment/标题-安卓小米.png'),
@@ -111,7 +113,12 @@ const maybeTriggerGuide = () => {
guideTimer.value = setTimeout(() => {
showGuide.value = true
guideTimer.value = null
}, 3000)
}, lastDeviceStayTime)
}
const handleEquipmentGuideComplete = () => {
showGuide.value = false
vlogStore.markGuideShown(equipmentGuideKey)
}
onBeforeUnmount(() => {
@@ -147,6 +154,7 @@ const selectMenuItem = (
}
selectedMenuIndex.value = index
currentIndex.value = 0
contentScrollRef.value?.scrollTo({ top: 0 })
viewedMenuKeys.value.add(item.id)
maybeTriggerGuide()
}
@@ -280,7 +288,10 @@ const prevPhone = () => {
alt=""
/>
</div>
<div class="plate-content">
<div
ref="contentScrollRef"
class="plate-content"
>
<div class="text">
<img
:src="currentContentImg"
@@ -310,6 +321,7 @@ const prevPhone = () => {
:highlight-padding="12"
:highlight-radius="20"
position-offset-y="85%"
@close="handleEquipmentGuideComplete"
/>
</div>
</template>
@@ -1,140 +0,0 @@
<script lang="tsx" setup>
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button'
import { ref } from 'vue'
import { ProgressBar } from '@/components'
import { useRouter } from 'vue-router'
const router = useRouter()
const currentStep = ref(2)
const handleComplete = () => {
console.log('完成前置条件')
// 处理完成逻辑
}
const handleStepChange = (step: number) => {
console.log('步骤变化:', step)
// 处理步骤变化逻辑
}
const handleWorkClick = () => {
console.log('任务取景按钮点击')
// 处理任务取景按钮点击逻辑
router.push({ name: 'Work' })
}
const handleClick = () => {
// 跳转设备准备
router.push({ name: 'equipment' })
}
const shooting = () => {
// 跳转实地取景
router.push({ name: 'shooting' })
}
const highlightShow = ref(true)
</script>
<template>
<div class="w-full h-full relative">
<!-- 背景图片-center -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.BG_CENTER) }"
></div>
<!-- 背景图片 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.BG) }"
></div>
<!-- 标题 -->
<div class="absolute top-2 left-1/2 -translate-x-1/2">
<ImgButton :image="IMAGE_PATHS.VLOG.TITLE" />
</div>
<!-- 任务取景按钮 -->
<div
class="absolute left-24 top-24"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.TASK_CAMERA) }"
>
<ImgButton :image="IMAGE_PATHS.VLOG.TASK_BTN" @click="handleWorkClick" />
</div>
<!-- 中期拍摄底板 -->
<div v-if="highlightShow" class="absolute right-90 ml-[135px]" style="z-index: 998">
<img :src="getStaticImage(IMAGE_PATHS.VLOG.Midterm)" />
</div>
<!-- 进度百分比 -->
<div
v-if="highlightShow"
class="absolute left-1/2 top-[12%] -translate-x-1/2 pointer-events-none"
>
<div class="flex">
<ImgButton :image="IMAGE_PATHS.VLOG.Progress" />
<ImgButton :image="IMAGE_PATHS.VLOG.Four" />
<ImgButton :image="IMAGE_PATHS.VLOG.Zero" />
<ImgButton :image="IMAGE_PATHS.VLOG.Percentage" />
</div>
</div>
<!-- 中期准备按钮文字 -->
<div
v-if="highlightShow"
class="absolute left-[21%] top-[9%] -translate-x-1/2"
style="z-index: 998"
>
<ImgButton
:image="IMAGE_PATHS.VLOG.MidtermEquipment"
@click="handleClick"
class="ml-[60px]"
/>
<ImgButton :image="IMAGE_PATHS.VLOG.MidtermFraming" @click="shooting" class="ml-[40px]" />
<ImgButton :image="IMAGE_PATHS.VLOG.MidtermShoot" class="ml-[20px]" />
</div>
<!-- 遮罩层 -->
<div class="Shadow" v-if="highlightShow">
<div>
<img src="" alt="" />
</div>
<img src="@/assets/images/midterm/引导遮罩.png" alt="" />
</div>
<!-- 叉 -->
<div
v-if="highlightShow"
class="absolute right-0 top-0"
style="z-index: 998; cursor: pointer"
@click="highlightShow = false"
>
<img src="@/assets/images/midterm/叉.png" alt="" />
</div>
<!-- 引导任务 -->
<!-- <ImgButton class="absolute right-24 bottom-24" :image="IMAGE_PATHS.VLOG.TASK_PERSON" /> -->
<div class="absolute bottom-0 w-full bright">
<ProgressBar
:current-step="currentStep"
@complete="handleComplete"
@step-change="handleStepChange"
/>
</div>
</div>
</template>
<style lang="less" scopeds>
.Shadow {
width: 100%;
height: 100%;
img {
width: 100%;
height: 100%;
z-index: 997 !important;
position: absolute;
}
}
.bright {
z-index: 999 !important;
}
</style>
@@ -5,8 +5,16 @@ import { ImgButton } from '@/components/Button'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import StepNavigation from '@/components/Guide/StepNavigation.vue'
import UnfinishedLearningPrompt from '@/components/UnfinishedLearningPrompt'
import QuestionQuiz, {
type Question,
} from '@/components/QuestionQuiz/QuestionQuiz.vue'
import { Video } from '@/components'
import { getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
import {
getPublicPath,
getStaticImage,
IMAGE_PATHS,
getVideoPoster,
} from '@/utils'
import { useVlogStore } from '@/stores/modules/vlog'
const router = useRouter()
@@ -14,6 +22,8 @@ const vlogStore = useVlogStore()
const guideKey = 'vlog-onsite-intro'
const showIntroGuide = ref(!vlogStore.guideShown[guideKey])
const playing = ref(false)
const showQuiz = ref(false)
const questions = ref<Question[]>([])
if (showIntroGuide.value) {
vlogStore.markGuideShown(guideKey)
@@ -48,6 +58,68 @@ const navigationSteps = ref([
},
])
const getQuestionsForVideo = (index: number): Question[] => {
if (index === 0) {
return [
{
id: 1,
title: '',
description:
'【判断题】开场镜头的主要作用之一,是让观众快速知道“这是在哪、正在做什么”。',
options: [
{ id: 'a', label: '正确' },
{ id: 'b', label: '错误' },
],
correctOptionId: 'a',
explanation:
'好的开场,能够快速建立时间、地点与事件背景,帮助观众迅速进入内容。',
},
]
}
if (index === 1) {
return [
{
id: 2,
title: '',
description: '【单选题】以下哪一种镜头,更适合突出“细节内容”?',
options: [
{ id: 'a', label: '全景' },
{ id: 'b', label: '特写' },
{ id: 'c', label: '远景' },
{ id: 'd', label: '航拍' },
],
correctOptionId: 'b',
explanation:
'特写镜头能够放大细节,让观众更清楚地看到动作、表情或物体特征。',
},
]
}
if (index === 2) {
return [
{
id: 3,
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: '跟拍不只是在“追人”,更是在保持观看的稳定性与空间感。',
},
]
}
return []
}
const completedNavigationSteps = computed(() =>
navigationSteps.value.map(
(step) => vlogStore.professionVideosWatched[step.id] === true,
@@ -72,28 +144,35 @@ const handleClose = () => {
}
const handleStepClick = (index: number) => {
showQuiz.value = false
currentVideoIndex.value = index
nextTick(() => {
playing.value = true
})
playing.value = true
}
const handleStepNavigationClose = () => {
showStepNavigation.value = false
}
const handleEnded = () => {
markCurrentVideoWatched()
const playNextVideo = () => {
if (currentVideoIndex.value >= navigationSteps.value.length - 1) {
playing.value = false
return
}
currentVideoIndex.value++
nextTick(() => {
playing.value = true
})
playing.value = true
}
const handleEnded = () => {
playing.value = false
questions.value = getQuestionsForVideo(currentVideoIndex.value)
showQuiz.value = true
}
const handleQuestionsComplete = () => {
markCurrentVideoWatched()
showQuiz.value = false
playNextVideo()
}
const handleIntroGuideComplete = () => {
@@ -126,6 +205,7 @@ onBeforeRouteLeave((to) => {
<div class="relative w-full h-full overflow-hidden bg-black">
<Video
:src="currentVideo"
:poster="getVideoPoster(currentVideo)"
v-model="playing"
:muted="false"
:volume="1"
@@ -159,12 +239,17 @@ onBeforeRouteLeave((to) => {
/>
</svg>
</button>
<QuestionQuiz
v-if="showQuiz"
:show-question-number="false"
:questions="questions"
@complete="handleQuestionsComplete"
/>
</template>
</Video>
<StepNavigation
:visible="showStepNavigation"
:background-image="getStaticImage('work/vlog背景全屏待定')"
:steps="navigationSteps"
:current-step-index="currentVideoIndex"
:completed-steps="completedNavigationSteps"
@@ -4,17 +4,22 @@ import { getBackgroundImage } from '@/utils'
import { ImgButton } from '@/components/Button'
import { Overlay } from '@/components/Overlay'
import { useRouter } from 'vue-router'
import { useVlogStore } from '@/stores/modules/vlog'
import { useViewfinderA } from './useViewfinderA'
import { useCanvasCapture } from '../composables/useCanvasCapture'
import StartOverlay from '../components/StartOverlay.vue'
import LoadingOverlay from '@/components/Loading/LoadingOverlay.vue'
const router = useRouter()
const vlogStore = useVlogStore()
const start = ref(false)
// 基础逻辑
const back = () => router.back()
const handleFinish = () => router.push({ name: 'VlogFilmingLocationB' })
const handleFinish = () => {
vlogStore.markViewed('VlogFilmingLocationA')
router.push({ name: 'VlogFilmingLocationB' })
}
// 截图功能
const { isLoading, showModal, capturedImageUrl, capture } = useCanvasCapture()
@@ -1,6 +1,6 @@
<script lang="ts" setup>
import { ref } from 'vue'
import { getBackgroundImage } from '@/utils'
import { getBackgroundImage, getPublicPath } from '@/utils'
import { ImgButton } from '@/components/Button'
import { Overlay } from '@/components/Overlay'
import { useViewfinderB } from './useViewfinderB'
@@ -9,10 +9,13 @@ import StartOverlay from '../components/StartOverlay.vue'
import LoadingOverlay from '@/components/Loading/LoadingOverlay.vue'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { useRouter } from 'vue-router'
import { useVlogStore } from '@/stores/modules/vlog'
const router = useRouter()
const vlogStore = useVlogStore()
const start = ref(false)
const showInstruction = ref<boolean>(false)
const isFirstVisit = !vlogStore.isRouteViewed('VlogFilmingLocationB')
type ResizeDirection = 'nw' | 'ne' | 'sw' | 'se'
@@ -35,6 +38,11 @@ const handleModalClose = () => {
showModal.value = false
}
const handleFinish = (): void => {
showModal.value = false
router.push({ name: 'VlogMidtermShooting' })
}
// 自由取景框模式
const {
viewfinderContainer,
@@ -109,6 +117,7 @@ const captureScene = async (): Promise<void> => {
)
},
onComplete: () => {
if (!isFirstVisit) return
// 延迟显示引导
setTimeout(() => {
showGuide.value = true
@@ -122,6 +131,7 @@ const captureScene = async (): Promise<void> => {
*/
const handleComplete = (): void => {
showGuide.value = false
vlogStore.markViewed('VlogFilmingLocationB')
router.push({
name: 'Vlog',
})
@@ -308,10 +318,14 @@ const handleComplete = (): void => {
/>
</div>
<!-- 操作按钮 -->
<!-- <div class="absolute bottom-1/2 -right-1/3 translate-y-1/2">
<ImgButton image="shooting/按钮-前往下一个取景地" @click="handleFinish" />
</div> -->
<!-- 完成按钮(非首次学习时显示) -->
<button
v-if="!isFirstVisit"
class="absolute -bottom-16 -rotate-6 ml-10 left-1/2 -translate-x-1/2 px-16 py-4 text-3xl font-bold text-white rounded-full bg-[#1b8fd8] shadow-xl hover:bg-[#1597d7] active:scale-95 transition-all"
@click="handleFinish"
>
完成
</button>
</div>
</Overlay>
</div>
@@ -327,8 +341,8 @@ const handleComplete = (): void => {
positionOffsetY="80%"
@complete="handleComplete"
>
<GuideStepsItem>
实地取景已全部完成,现在跟着我走进现场开始正式的拍摄吧~
<GuideStepsItem :audioSrc="getPublicPath('audio/py_2_022.MP3')">
实地取景已全部完成,现在跟着我走进现场,开始正式的拍摄吧!
</GuideStepsItem>
</GuideSteps>
</div>
@@ -3,9 +3,9 @@ import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { ImgButton } from '@/components/Button'
import { getBackgroundImage, getPublicPath } from '@/utils'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/utils'
import shootingtitle from '@/assets/images/shooting/标题-实地取景.png'
import videoSrc from '/video/xinshou_jiuzhonggoutufa.mp4'
import videoSrc from '/video/new/composition/xsdh_jzgtf_batch.mp4'
import KnowledgeCard from '@/components/KnowledgeCard/KnowledgeCard.vue'
import CompositionGuide from '@/components/CompositionGuide.vue'
import { useVlogStore } from '@/stores/modules/vlog'
@@ -79,16 +79,32 @@ const back = () => {
</div>
<div class="absolute left-[12%] top-1/2 -translate-y-[calc(50%-70px)]">
<ImgButton
image="vlog/shooting/热带植物馆-入口.png"
@click="handleClickA"
/>
<div class="relative inline-block">
<ImgButton
image="vlog/shooting/热带植物馆-入口.png"
@click="handleClickA"
/>
<img
v-if="vlogStore.isRouteViewed('VlogFilmingLocationA')"
:src="getStaticImage('midterm/完成.png')"
class="absolute -top-8 right-6 w-24 pointer-events-none"
alt="已完成"
/>
</div>
</div>
<div class="absolute right-[12%] top-1/2 -translate-y-[calc(50%-70px)]">
<ImgButton
image="vlog/shooting/沉水栈道-入口.png"
@click="handleClickB"
/>
<div class="relative inline-block">
<ImgButton
image="vlog/shooting/沉水栈道-入口.png"
@click="handleClickB"
/>
<img
v-if="vlogStore.isRouteViewed('VlogFilmingLocationB')"
:src="getStaticImage('midterm/完成.png')"
class="absolute -top-8 right-14 w-24 pointer-events-none"
alt="已完成"
/>
</div>
</div>
<div class="absolute bottom-4 left-4 btn-1">
<ImgButton
+90 -10
View File
@@ -3,6 +3,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button'
import { StarRating } from '@/components/Rating'
import { GuideSteps, type GuideStep } from '@/components/Guide'
import { useRouter } from 'vue-router'
import BackToTopIcon from '@/assets/images/components/置顶.png'
import { useVlogStore } from '@/stores/modules/vlog'
@@ -20,6 +21,77 @@ const worksScrollIndex = ref<number>(0)
const animatingToPage = ref<number>(0)
const containerRef = ref<HTMLElement | null>(null)
const isAnimating = ref<boolean>(false)
const visible = ref<boolean>(false)
const guideKeyPrefix = 'vlog-practice-page'
const pageGuideSteps: Record<number, GuideStep[]> = {
0: [
{
id: 'vlog-practice-page-0-2',
content: '这里可以查看任务描述。',
highlightSelector: '.desc-img',
positionOffsetY: '87%',
audioSrc: '/audio/py_6_002.MP3',
},
{
id: 'vlog-practice-page-0-3',
content: '这里可以查看任务具体要求。',
highlightSelector: '.task-btn',
positionOffsetY: '50%',
audioSrc: '/audio/py_6_003.MP3',
},
{
id: 'vlog-practice-page-0-4',
content: '点击下滑箭头,可查看更多学习与实践内容。',
highlightSelector: '.down-scroll-hint',
audioSrc: '/audio/py_6_005.MP3',
},
],
1: [
{
id: 'vlog-practice-page-1-1',
content: '点击【我的创作笔记】,记录自己的创作思路与作品说明。',
highlightSelector: '.my-notes-btn',
audioSrc: '/audio/py_6_004.MP3',
},
],
2: [
{
id: 'vlog-practice-page-2-1',
content: '点击【查看】按钮,欣赏学长学姐们的优秀实践作品。',
highlightSelector: '.view-btn',
positionOffsetY: '50%',
audioSrc: '/audio/py_6_006.MP3',
},
{
id: 'vlog-practice-page-2-2',
content: '点击【教师点评】按钮,查看老师给出的指导与建议。',
highlightSelector: '.teacher-comment-btn',
positionOffsetY: '50%',
audioSrc: '/audio/py_6_007.MP3',
},
{
id: 'vlog-practice-page-2-3',
content: '点击向上按钮,可快速返回实战操作台首页。',
highlightSelector: '.back-to-top-btn',
positionOffsetY: '50%',
audioSrc: '/audio/py_6_009.MP3',
},
],
}
const currentGuideKey = computed(() => `${guideKeyPrefix}-${currentPage.value}`)
const currentGuideSteps = computed(
() => pageGuideSteps[currentPage.value] ?? [],
)
const showPageGuide = (): void => {
if (vlogStore.guideShown[currentGuideKey.value]) return
visible.value = true
}
const handlePageGuideComplete = (): void => {
visible.value = false
vlogStore.markGuideShown(currentGuideKey.value)
}
/** 是否为最后一页 */
const isLastPage = computed<boolean>(() => currentPage.value >= TOTAL_PAGES - 1)
@@ -54,8 +126,7 @@ const worksList = ref<WorkItem[]>([
title: '熙熙攘攘我们的劳动',
stars: 4,
path: '/video/vlog/practice/vlog_case1_batch.mp4',
description:
'作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介',
description: '26084 曹毅诚 杨子悦 张馨月 刘政亚 吴若涵 叶雨鑫',
},
{
id: 2,
@@ -63,8 +134,7 @@ const worksList = ref<WorkItem[]>([
title: '匠心精神劳动承匠心',
stars: 5,
path: '/video/vlog/practice/vlog_case2_batch.mp4',
description:
'作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介',
description: '26081 刘欢欢 锁怡雯 苏佳琪 陶恩泽 王子严 史贺元',
},
])
@@ -126,6 +196,7 @@ const smoothScrollTo = (toPage: number): void => {
} else {
isAnimating.value = false
currentPage.value = toPage
showPageGuide()
}
}
@@ -190,6 +261,7 @@ const saveScrollPage = (): void => {
onMounted(() => {
restoreScrollPage()
showPageGuide()
containerRef.value?.addEventListener('wheel', handleWheel, { passive: false })
})
@@ -211,16 +283,16 @@ onUnmounted(() => {
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
/>
<!-- 实战操作台 -->
<!-- 操作描述 -->
<div class="w-full h-[1080px] bg-[#F5EFE6] relative">
<ImgButton
class="absolute left-40 bottom-28 z-10"
class="task-btn absolute left-40 bottom-28 z-10"
image="vlog/practice/查看任务要求"
@click="scrollToNextPage"
/>
<!-- img -->
<img
class="absolute left-0 top-0 z-10"
class="desc-img absolute left-0 top-0 z-10"
draggable="false"
:src="getStaticImage('vlog/practice/vlog')"
alt=""
@@ -238,7 +310,7 @@ onUnmounted(() => {
:style="{ backgroundImage: getBackgroundImage('vlog/practice/背景2') }"
>
<ImgButton
class="absolute right-20 bottom-0 z-99"
class="my-notes-btn absolute right-20 bottom-0 z-99"
image="vlog/practice/我的创作笔记btn"
path="/vlog/practice/my-notes"
/>
@@ -314,6 +386,7 @@ onUnmounted(() => {
</p>
<!-- 查看按钮 -->
<ImgButton
:class="work.id === worksList[0]?.id ? 'view-btn' : ''"
image="vlog/practice/查看"
:path="`/vlog/practice/video/${encodeURIComponent(work.path)}`"
/>
@@ -357,7 +430,7 @@ onUnmounted(() => {
<!-- 回到顶部按钮 - 最后一页显示 -->
<img
v-show="isLastPage"
class="absolute bottom-10 left-1/2 -translate-x-1/2 cursor-pointer transition-all duration-300 hover:scale-105"
class="back-to-top-btn absolute bottom-10 left-1/2 -translate-x-1/2 cursor-pointer transition-all duration-300 hover:scale-105"
:src="BackToTopIcon"
@click="scrollToTop"
/>
@@ -365,7 +438,7 @@ onUnmounted(() => {
<!-- 老师点评按钮 - 最后一页显示 -->
<ImgButton
v-show="isLastPage"
class="absolute right-2 bottom-10 z-30"
class="teacher-comment-btn absolute right-2 bottom-10 z-30"
image="components/教师点评按钮"
path="/vlog/practice/teacher-comment"
/>
@@ -396,6 +469,13 @@ onUnmounted(() => {
/>
</Transition>
</div>
<GuideSteps
v-if="visible"
:steps="currentGuideSteps"
position-offset-y="70%"
@complete="handlePageGuideComplete"
/>
</div>
</template>
+11 -6
View File
@@ -45,18 +45,18 @@ const defaultEvaluationItems: PracticeRatingItem[] = [
{
title: '开场建立',
description: '能清晰交代时间、地点与事件开端,观众易于进入情境',
score: 4,
score: 0,
},
{
title: '拍摄表达',
description:
'合理运用镜头语言(如推、拉、摇、跟拍等),能够表现空间关系或记录真实过程',
score: 2,
score: 0,
},
{
title: '真实捕捉',
description: '能捕捉到具有现场感的瞬间(如互动、行动、细节),画面不生硬',
score: 5,
score: 0,
},
{
title: '剪辑结构',
@@ -66,12 +66,12 @@ const defaultEvaluationItems: PracticeRatingItem[] = [
{
title: '音画关系',
description: '画面与配音/环境音基本匹配,能辅助理解内容',
score: 5,
score: 0,
},
{
title: '综合感受',
description: '视频完整、有连贯观看体验,能表达出一次真实记录或个人感受',
score: 5,
score: 0,
},
]
@@ -80,7 +80,11 @@ const evaluationItems = ref<PracticeRatingItem[]>(
)
const handleSave = (): void => {
const content = noteContent.value.trim()
if (!content) return
store.setPracticeNoteContent(noteContent.value)
store.markViewed('VlogPractice')
showSuccess.value = true
window.clearTimeout(successTimer)
successTimer = window.setTimeout(() => {
@@ -127,6 +131,7 @@ onBeforeUnmount(() => {
<div class="absolute bottom-24 left-1/2 -translate-x-1/2 flex gap-60">
<ImgButton
image="vlog/practice/保存"
:disabled="disabled"
@click="handleSave"
/>
<ImgButton
@@ -140,7 +145,7 @@ onBeforeUnmount(() => {
:visible="showModal"
v-model:items="evaluationItems"
background-image="vlog/practice/Vlog作品评分表"
top-tip="评分时要关注6个维度哦~分别是开场建立、拍摄表达、真实捕捉、剪辑结构、音画关系、综合感受,每个维度满分5分,能打1-5分,记得对照作品表现来打分呀~"
top-tip="评分时要关注6个维度哦!分别是开场建立、拍摄表达、真实捕捉、剪辑结构、音画关系、综合感受,每个维度满分5分,能打1-5分,记得对照作品表现来打分呀~"
bottom-tip="己评价的实战作品,从封面入口就能直接看到具体评分!打了多少分一目了然,不用再点进详情页翻找啦~"
@save="handleSaveEvaluation"
@close="showModal = false"
@@ -13,7 +13,7 @@ const handleBack = (): void => {
<template>
<div class="relative w-full h-full overflow-x-hidden">
<ImgButton
class="absolute right-0 top-0 z-50"
class="fixed right-0 top-0 z-50"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleBack"
/>
+27 -8
View File
@@ -5,13 +5,21 @@ import { ImgButton } from '@/components/Button'
import PracticeRatingModal, {
type PracticeRatingItem,
} from '@/components/PracticeRatingModal'
import { IMAGE_PATHS, getPublicPath } from '@/utils'
import { IMAGE_PATHS, getPublicPath, getVideoPoster } from '@/utils'
import { useVlogStore } from '@/stores/modules/vlog'
import { computed, ref } from 'vue'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
const router = useRouter()
const store = useVlogStore()
const guideKey = 'vlog-practice-video-rating'
const showGuide = ref(!store.guideShown[guideKey])
const handleRatingGuideComplete = () => {
store.markGuideShown(guideKey)
}
const props = defineProps({
path: {
type: String,
@@ -40,18 +48,18 @@ const defaultEvaluationItems: PracticeRatingItem[] = [
{
title: '开场建立',
description: '能清晰交代时间、地点与事件开端,观众易于进入情境',
score: 4,
score: 0,
},
{
title: '拍摄表达',
description:
'合理运用镜头语言(如推、拉、摇、跟拍等),能够表现空间关系或记录真实过程',
score: 2,
score: 0,
},
{
title: '真实捕捉',
description: '能捕捉到具有现场感的瞬间(如互动、行动、细节),画面不生硬',
score: 5,
score: 0,
},
{
title: '剪辑结构',
@@ -61,12 +69,12 @@ const defaultEvaluationItems: PracticeRatingItem[] = [
{
title: '音画关系',
description: '画面与配音/环境音基本匹配,能辅助理解内容',
score: 5,
score: 0,
},
{
title: '综合感受',
description: '视频完整、有连贯观看体验,能表达出一次真实记录或个人感受',
score: 5,
score: 0,
},
]
@@ -98,12 +106,13 @@ const handleBack = (): void => {
@click="handleBack"
/>
<ImgButton
class="absolute right-0 bottom-10 z-50"
class="absolute right-0 top-1/2 -translate-y-1/2 z-50"
image="vlog/practice/评分"
@click="showModal = true"
/>
<Video
:src="getPublicPath(decodeURIComponent(props.path))"
:poster="getVideoPoster(getPublicPath(decodeURIComponent(props.path)))"
:volume="1"
:muted="false"
:controls="true"
@@ -114,10 +123,20 @@ const handleBack = (): void => {
:visible="showModal"
v-model:items="evaluationItems"
background-image="vlog/practice/Vlog作品评分表"
top-tip="评分时要关注6个维度哦~分别是开场建立、拍摄表达、真实捕捉、剪辑结构、音画关系、综合感受,每个维度满分5分,能打1-5分,记得对照作品表现来打分呀~"
top-tip="评分时要关注6个维度哦!分别是开场建立、拍摄表达、真实捕捉、剪辑结构、音画关系、综合感受,每个维度满分5分,能打1-5分,记得对照作品表现来打分呀~"
bottom-tip="己评价的实战作品,从封面入口就能直接看到具体评分!打了多少分一目了然,不用再点进详情页翻找啦~"
@save="handleSaveEvaluation"
@close="showModal = false"
/>
<GuideSteps
:visible="showGuide"
highlight-selector=".btn-1"
position-offset-y="60%"
@complete="handleRatingGuideComplete"
>
<GuideStepsItem audio-src="/audio/py_6_008.MP3">
点击【评分】,为你喜爱的作品送上五星好评。
</GuideStepsItem>
</GuideSteps>
</div>
</template>
+56 -7
View File
@@ -2,6 +2,8 @@
import { IMAGE_PATHS, getStaticImage } from '@/utils'
import { ImgButton } from '@/components/Button'
import { useRouter } from 'vue-router'
import { useScale } from '@/composables/useScale'
import { ImagePreview } from '@/components'
// import { GuideSteps, GuideStepsItem } from '@/components/Guide'
const router = useRouter()
@@ -9,6 +11,34 @@ const router = useRouter()
const back = () => {
router.back()
}
const { scale, offsetX, offsetY } = useScale()
const handleOpenPlantMap = () => {
ImagePreview({
images: [getStaticImage('vlog/img/上海植物园地图.jpg')],
startIndex: 0,
maskClosable: true,
getScaleMetrics: () => ({
scale: scale.value,
offsetX: offsetX.value,
offsetY: offsetY.value,
}),
})
}
const handleOpenVolunteerList = () => {
ImagePreview({
images: [getStaticImage('vlog/img/上海植物园志愿者服务岗位清单.png')],
startIndex: 0,
maskClosable: true,
getScaleMetrics: () => ({
scale: scale.value,
offsetX: offsetX.value,
offsetY: offsetY.value,
}),
})
}
</script>
<template>
@@ -19,17 +49,36 @@ const back = () => {
@click="back"
/>
<!-- 背景 -->
<img class="absolute inset-0" :src="getStaticImage('img/背景.png')" alt="" />
<img
class="absolute inset-0"
:src="getStaticImage('img/背景.png')"
alt=""
/>
<!-- 标题 -->
<img class="absolute top-5 z-10" :src="getStaticImage('img/资源筹备-标题.png')" alt="" />
<img
class="absolute top-5 z-10"
:src="getStaticImage('img/资源筹备-标题.png')"
alt=""
/>
<div class="relative h-full pt-28 overflow-y-scroll">
<img :src="getStaticImage('vlog/img/资源筹备.png')" alt="" />
</div>
<img
:src="getStaticImage('vlog/img/资源筹备.png')"
alt=""
/>
<!-- <GuideSteps :visible="true" position-offset-y="85%">
<GuideStepsItem>这份长图文干货满满,滚动鼠标查看完整内容吧</GuideStepsItem>
</GuideSteps> -->
<!-- 植物园地图 -->
<button
class="absolute top-[2910px] left-1/2 -translate-x-1/2 w-[260px] h-[80px] ml-6"
@click="handleOpenPlantMap"
/>
<!-- 志愿者服务岗位清单 -->
<button
class="absolute top-[3050px] left-1/2 -translate-x-1/2 w-[460px] h-[80px] ml-6"
@click="handleOpenVolunteerList"
/>
</div>
</div>
</template>
+22 -9
View File
@@ -40,30 +40,41 @@ const back = () => {
<div
:class="[
'absolute h-full left-0 top-0 transition-transform duration-300 z-10',
hiddenbar ? 'translate-x-[-400px]' : 'translate-x-[0px]'
hiddenbar ? 'translate-x-[-400px]' : 'translate-x-[0px]',
]"
>
<img class="absolute inset-0 -z-10" src="@/assets/images/equipment/侧边导航栏.png" alt="" />
<img
class="absolute inset-0 -z-10"
src="@/assets/images/equipment/侧边导航栏.png"
alt=""
/>
<!-- 脚本撰写 -->
<img class="" :src="getStaticImage(IMAGE_PATHS.VLOG.SCRIPTINGTITLE)" />
<img
class=""
:src="getStaticImage(IMAGE_PATHS.VLOG.SCRIPTINGTITLE)"
/>
<!-- <img
class="absolute bottom-4 left-6"
src="@/assets/images/vlog/制作变焦镜/左边导航栏/ai分镜工具.png"
alt=""
/>
/> -->
<div class="absolute left-0 top-36">
<ImgButton
:image="
writingtext == '撰写格式' ? IMAGE_PATHS.VLOG.FORMATLIANG : IMAGE_PATHS.VLOG.FORMATAN
writingtext == '撰写格式'
? IMAGE_PATHS.VLOG.FORMATLIANG
: IMAGE_PATHS.VLOG.FORMATAN
"
@click="formatClick"
/>
<ImgButton
:image="
writingtext == '样例展示' ? IMAGE_PATHS.VLOG.SAMPLELANG : IMAGE_PATHS.VLOG.SAMPLEAN
writingtext == '样例展示'
? IMAGE_PATHS.VLOG.SAMPLELANG
: IMAGE_PATHS.VLOG.SAMPLEAN
"
@click="sampleanClick"
/>
@@ -73,11 +84,13 @@ const back = () => {
<div
:class="[
'absolute transition-transform duration-200 top-1/2 -translate-y-1/2 z-20',
hiddenbar ? ' translate-x-[-38px]' : 'absolute translate-x-[270px]'
hiddenbar ? ' translate-x-[-38px]' : 'absolute translate-x-[270px]',
]"
@click="hiddenbarclick()"
>
<ImgButton :image="hiddenbar ? 'equipment/按钮-展开' : 'equipment/按钮-收缩'" />
<ImgButton
:image="hiddenbar ? 'equipment/按钮-展开' : 'equipment/按钮-收缩'"
/>
</div>
<div class="absolute w-full h-full left-0 top-0">
+15 -4
View File
@@ -1,13 +1,24 @@
<script lang="tsx" setup>
import fourth from '/video/vlog_renwuqujing_jiaobencehua.mp4'
// import fourth from '/video/vlog_renwuqujing_jiaobencehua.mp4'
import { Video } from '@/components'
import { getStaticImage } from '@/utils'
import { getPublicPath, getStaticImage, getVideoPoster } from '@/utils'
</script>
<template>
<div class="relative w-full h-full">
<div class="absolute top-8 left-1/2 -translate-x-1/2 z-10">
<img :src="getStaticImage('vlog/script-writing/脚本概念素养.png')" alt="" />
<img
:src="getStaticImage('vlog/script-writing/脚本概念素养.png')"
alt=""
/>
</div>
<Video :src="fourth" controls :muted="false" :volume="1"></Video>
<Video
:src="getPublicPath('video/film/script-writing/jiaoben.mp4')"
:poster="
getVideoPoster(getPublicPath('video/film/script-writing/jiaoben.mp4'))
"
controls
:muted="false"
:volume="1"
></Video>
</div>
</template>
+35 -48
View File
@@ -1,16 +1,18 @@
<script lang="ts" setup>
import { computed, nextTick, onMounted, ref } from 'vue'
import { computed, ref } from 'vue'
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 { Video, ImagePreview } from '@/components'
import { useScale } from '@/composables/useScale'
import {
getBackgroundImage,
getPublicPath,
getStaticImage,
IMAGE_PATHS,
getVideoPoster,
} from '@/utils'
import { useVlogStore } from '@/stores'
@@ -18,33 +20,13 @@ const router = useRouter()
const route = useRoute()
const vlog = useVlogStore()
const { scale, offsetX, offsetY } = useScale()
// 受控播放开关(用于切换到下一个视频时自动播放)
const playing = ref(false)
const status = ref(false)
//引导步骤
const ideaSteps = [
{
id: 'idea-step1',
content: '一条优秀的 Vlog,往往不是记录"大事",而是认真记录生活里的小事。',
audioSrc: getPublicPath('audio/py_2_004.MP3'),
},
{
id: 'idea-step2',
content:
'试着把刚才学到的方法用起来,你会发现,原来平凡的日子里,也藏着许多值得被记录的瞬间。',
audioSrc: getPublicPath('audio/py_2_005.MP3'),
},
]
const steps = ref<{ id: string; content: string; audioSrc?: string }[]>([
{
id: 'step1',
content:
'试着用这些方法记录身边的小事,你会发现,原来平凡的日子里有这么多值得捕捉的瞬间。',
},
])
const steps = ref<{ id: string; content: string; audioSrc?: string }[]>([])
// 步骤导航数据
const navigationSteps = ref([
@@ -142,33 +124,43 @@ const handleVideoChange = () => {
currentVideoIndex.value++
currentNavigationStep.value = currentVideoIndex.value
} else {
if (vlog.guideShown['work-profession-complete']) {
return
}
status.value = true
steps.value = [
{
id: 'step1',
audioSrc: getPublicPath('audio/py_2_023.MP3'),
content:
'恭喜你,已完成专业剖析的学习,点击【查看脚本】回顾视频脚本吧!',
'恭喜你!完成了专业剖析的全部内容,点击【返回】按钮查看本案例的【思路提点】。',
},
]
return
}
nextTick(() => {
// 使用受控播放来触发下一个视频自动播放
playing.value = true
})
playing.value = true
}
const handleClose = () => {
if (key.value === 'idea') {
vlog.markWorkVideoWatched('idea')
if (vlog.vlogUnlockProgress === 0) vlog.setVlogUnlockProgress(1)
router.push({
name: 'Vlog',
query: { guide: 'return' },
})
return
}
router.back()
vlog.requestWorkCompletionCheck()
router.push({ name: 'VlogWork' })
}
const ideaImage = computed(() => getStaticImage('vlog/work/思路提点全屏'))
const handleIdeaImageClick = () => {
ImagePreview({
images: [ideaImage.value],
maskClosable: true,
getScaleMetrics: () => ({
scale: scale.value,
offsetX: offsetX.value,
offsetY: offsetY.value,
}),
})
}
const image = computed(() => route.query.image as string)
@@ -201,8 +193,10 @@ const onEnded = () => {
const handleComplete = () => {
if (key.value === 'profession') {
vlog.markGuideShown('work-profession-complete')
vlog.markWorkVideoWatched('profession')
if (!vlog.isVlogUnlock) vlog.setIsVlogUnlock(true)
vlog.requestWorkCompletionCheck()
router.push({
name: 'VlogWork',
})
@@ -213,9 +207,7 @@ const handleComplete = () => {
const handleStepClick = (index: number) => {
currentVideoIndex.value = index
currentNavigationStep.value = index
nextTick(() => {
playing.value = true
})
playing.value = true
}
const handleStepNavigationClose = () => {
@@ -240,13 +232,6 @@ onBeforeRouteLeave((to) => {
showUnfinishedPrompt.value = true
return false
})
onMounted(() => {
if (key.value === 'idea') {
steps.value = ideaSteps
status.value = true
}
})
</script>
<template>
@@ -308,6 +293,7 @@ onMounted(() => {
<Video
v-else-if="key === 'case' || key === 'profession'"
:src="resolvedSrc"
:poster="getVideoPoster(resolvedSrc)"
v-model="playing"
:muted="false"
:volume="1"
@@ -369,9 +355,11 @@ onMounted(() => {
<div
v-else-if="key === 'idea'"
:src="image"
class="cursor-pointer"
@click="handleIdeaImageClick"
>
<img
:src="getStaticImage('work/思路提点全屏')"
:src="ideaImage"
alt=""
/>
</div>
@@ -388,7 +376,6 @@ onMounted(() => {
<!-- 步骤导航抽屉 -->
<StepNavigation
:visible="showStepNavigation && key === 'profession'"
:background-image="getStaticImage('work/vlog背景全屏待定')"
:steps="navigationSteps"
:current-step-index="currentNavigationStep"
:completed-steps="completedNavigationSteps"
@@ -10,6 +10,7 @@
<script setup lang="ts">
import { ImageSwitchList } from '@/components/Switcher'
import { useVlogStore } from '@/stores'
import { computed } from 'vue'
type WorkTab = 'vlog' | 'case' | 'profession' | 'idea'
@@ -26,41 +27,50 @@ interface Emits {
const props = withDefaults(defineProps<Props>(), {
modelValue: 'case',
itemClass: 'w-[260px] h-[64px] rounded-[18px]'
itemClass: 'w-[260px] h-[64px] rounded-[18px]',
})
const emit = defineEmits<Emits>()
const vlog = useVlogStore()
const getStatusImage = (key: Exclude<WorkTab, 'vlog'>) =>
vlog.workVideosWatched[key]
? 'work/任务取景框-完成'
: 'work/任务取景框-未完成'
const innerValue = computed({
get: () => props.modelValue as WorkTab,
set: (val: WorkTab) => emit('update:modelValue', val)
set: (val: WorkTab) => emit('update:modelValue', val),
})
const items = [
const items = computed(() => [
// { key: 'vlog', activeImage: 'work/vlog初识-当前', inactiveImage: 'work/vlog初识-未选中' },
{
key: 'case',
label: '案例赏析',
activeImage: 'work/选中_带三角',
inactiveImage: 'work/未选中_带三角'
inactiveImage: 'work/未选中_带三角',
statusImage: getStatusImage('case'),
},
{
key: 'profession',
label: '专业剖析',
activeImage: 'work/选中_带三角',
inactiveImage: 'work/未选中_带三角'
inactiveImage: 'work/未选中_带三角',
statusImage: getStatusImage('profession'),
},
{
key: 'idea',
label: '思路提点',
activeImage: 'work/选中_带三角',
inactiveImage: 'work/未选中_带三角'
}
]
inactiveImage: 'work/未选中_带三角',
statusImage: getStatusImage('idea'),
},
])
const onChange = (val: string) => emit('change', val as WorkTab)
defineExpose({
items
items,
})
</script>
+63 -12
View File
@@ -1,6 +1,6 @@
<script lang="tsx" name="VlogWork" setup>
defineOptions({ name: 'VlogWork' })
import { ref, computed } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import {
getBackgroundImage,
getPublicPath,
@@ -9,7 +9,7 @@ import {
} from '@/utils'
import { ImgButton } from '@/components/Button'
import WorkSideSwitcher from './components/WorkSideSwitcher.vue'
import { GuideSteps } from '@/components/Guide'
import { GuideSteps, type GuideStep } from '@/components/Guide'
import { useRouter } from 'vue-router'
import { useScale } from '@/composables/useScale'
import { ImagePreview } from '@/components'
@@ -29,6 +29,48 @@ const workSideSwitcherRef = ref<InstanceType<typeof WorkSideSwitcher>>()
const isGuideVlogTask = computed(() => vlog.isGuideVlogTask)
const isVlogUnlock = computed(() => vlog.isVlogUnlock)
const showReturnGuide = ref(false)
const allWorkCompleted = computed(() =>
['case', 'profession', 'idea'].every((key) =>
Boolean(vlog.workVideosWatched[key as Exclude<WorkTab, 'vlog'>]),
),
)
const returnSteps: GuideStep[] = [
{
id: 'idea-step1',
content: '一条优秀的 Vlog,往往不是记录"大事",而是认真记录生活里的小事。',
audioSrc: getPublicPath('audio/py_2_004.MP3'),
},
{
id: 'idea-step2',
content:
'试着把刚才学到的方法用起来,你会发现,原来平凡的日子里,也藏着许多值得被记录的瞬间。',
audioSrc: getPublicPath('audio/py_2_005.MP3'),
},
]
const handleReturnGuideComplete = () => {
showReturnGuide.value = false
vlog.setVlogUnlockProgress(1)
router.replace({ name: 'Vlog', query: { guide: 'return' } })
}
const completeWorkFlow = () => {
if (!allWorkCompleted.value || !vlog.isGuideVlogReturn) return
showReturnGuide.value = true
}
watch(allWorkCompleted, (completed, wasCompleted) => {
if (!completed || wasCompleted) return
completeWorkFlow()
})
onMounted(() => {
if (!vlog.consumeWorkCompletionCheck()) return
if (!vlog.isGuideVlogReturn) return
completeWorkFlow()
})
//引导步骤
const steps = [
@@ -36,21 +78,20 @@ const steps = [
id: 'step1',
content: '现在我们要一起走进 Vlog 的世界。',
audioSrc: getPublicPath('audio/py_2_001.MP3'),
highlightSelector: '.work-border',
},
{
id: 'step2',
content:
'Vlog 是用视频记录生活的一种方式。它像一本会动的日记,能把日常里的小事、真实的情绪,以及那些稍纵即逝的瞬间,用镜头生动地保存下来。',
audioSrc: getPublicPath('audio/py_2_002.MP3'),
highlightSelector: '.work-border',
highlightSelector: '.img-switch-item--case',
},
{
id: 'step3',
content:
'接下来,我们通过一个具体案例,一起看看Vlog是如何记录生活中的美好的。',
audioSrc: getPublicPath('audio/py_2_003.MP3'),
highlightSelector: '.img-switch-item--case',
highlightSelector: '.work-border',
},
]
@@ -59,22 +100,22 @@ const sideSwitcherItems = ref([
{
key: 'vlog',
description: '记录身边的美好',
image: 'work/vlog背景全屏待定',
image: 'vlog/work/vlog背景全屏待定',
},
{
key: 'case',
description: '案例赏析-桂花酒酿小圆子',
image: 'work/案例背景待定',
image: 'vlog/work/案例背景待定',
},
{
key: 'profession',
description: '专业剖析-桂花酒酿小圆子',
image: 'work/专业剖析小屏',
image: 'vlog/work/专业剖析小屏',
},
{
key: 'idea',
description: '思路提点-桂花酒酿小圆子',
image: 'work/思路提点小屏',
image: 'vlog/work/思路提点小屏',
},
])
@@ -183,7 +224,7 @@ function openImages(urls: string[], startIndex = 0) {
</div>
<!-- 左侧选项切换 -->
<div class="absolute right-0 top-60 z-20">
<div class="absolute right-0 top-1/2 -translate-y-1/2 z-20">
<WorkSideSwitcher
ref="workSideSwitcherRef"
v-model="selected"
@@ -193,13 +234,13 @@ function openImages(urls: string[], startIndex = 0) {
</div>
<!-- 任务取景按钮 -->
<ImgButton
<!-- <ImgButton
class="absolute right-14 bottom-10"
:image="
isVlogUnlock ? IMAGE_PATHS.WORK.TASK_BTN_OK : IMAGE_PATHS.WORK.TASK_BTN
"
@click="handleWorkClick"
/>
/> -->
<!-- 引导与高亮 -->
<GuideSteps
@@ -212,6 +253,16 @@ function openImages(urls: string[], startIndex = 0) {
position-offset-y="85%"
/>
<GuideSteps
v-if="showReturnGuide"
:steps="returnSteps"
:highlight-padding="12"
:highlight-radius="20"
@complete="handleReturnGuideComplete"
@skip="handleReturnGuideComplete"
position-offset-y="85%"
/>
<!-- 引导词按钮 -->
<ImgButton
class="absolute w-[120px] bottom-0 right-0 z-10"