forked from zhongyidian/super_video
统一引导提示的持久化控制,补充学习进度入口与模块资源,避免用户返回页面时重复触发提示。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
520 lines
16 KiB
Vue
520 lines
16 KiB
Vue
<script lang="ts" setup>
|
||
defineOptions({
|
||
name: 'Action'
|
||
})
|
||
import { getBackgroundImage, getPublicPath, IMAGE_PATHS, getStaticImage } from '@/utils'
|
||
import { ImgButton } from '@/components/Button'
|
||
import { ModuleSwitcher } from './components/ModuleSwitcher'
|
||
import StudyProgressPopup from './components/StudyProgressPopup.vue'
|
||
import { computed, ref, onMounted } from 'vue'
|
||
import { useRouter, type RouteRecordName } from 'vue-router'
|
||
import {
|
||
useUserStore,
|
||
useFilmStore,
|
||
useVlogStore,
|
||
useInterviewStore,
|
||
useBasicElementsStore
|
||
} from '@/stores'
|
||
import { GuideSteps } from '@/components/Guide'
|
||
import type { GuideStep } from '@/components/Guide/types'
|
||
|
||
type GuideChildNode = {
|
||
id: number
|
||
label: string
|
||
icon: string
|
||
routeName: RouteRecordName
|
||
}
|
||
|
||
type GuideNode = {
|
||
id: number
|
||
num: string
|
||
label: string
|
||
x: number
|
||
y: number
|
||
hasChildren: boolean
|
||
routeName?: RouteRecordName
|
||
children?: GuideChildNode[]
|
||
}
|
||
|
||
const actionGuideKey = 'home-action-guide'
|
||
const activeActionGuide = ref<'home' | 'unlocked' | 'element' | null>(null)
|
||
const studyProgressVisible = ref(false)
|
||
|
||
const homeGuideSteps: GuideStep[] = [
|
||
{
|
||
id: 'home-guide-navigation',
|
||
content: '点击新手导航按钮,可以展开/收起新手导航面板!',
|
||
positionOffsetY: '50%',
|
||
highlightSelector: '.newUserGuide',
|
||
audioSrc: getPublicPath('audio/py_0_005.MP3')
|
||
},
|
||
{
|
||
id: 'home-guide-locked-modules',
|
||
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: '点击技能树,可以一键回顾新手导航和三大创作板块所有的知识点哦!',
|
||
highlightSelector: '.skill-tree',
|
||
positionOffsetY: '50%',
|
||
audioSrc: getPublicPath('audio/py_0_009.MP3')
|
||
}
|
||
]
|
||
|
||
const unlockedHomeGuideSteps: GuideStep[] = [
|
||
{
|
||
id: 'home-guide-welcome',
|
||
content: '欢迎来到新手村!在这里,我会带着你解锁短视频创作的核心基本功!',
|
||
audioSrc: getPublicPath('audio/py_1_001.MP3')
|
||
},
|
||
{
|
||
id: 'home-guide-map',
|
||
content: '我们会读懂构图、用好光线、灵活运镜,了解基础拍摄技巧和剪映软件的基本操作。',
|
||
audioSrc: getPublicPath('audio/py_1_002.MP3')
|
||
},
|
||
{
|
||
id: 'home-guide-start-node',
|
||
content: '选择任意节点开始学习~',
|
||
highlightSelector: '.guide-content',
|
||
positionOffsetY: '20%',
|
||
audioSrc: getPublicPath('audio/py_1_003.MP3')
|
||
}
|
||
]
|
||
|
||
const elementGuideSteps: GuideStep[] = [
|
||
{
|
||
id: 'home-guide-start-element',
|
||
content: '选择任意要素开始学习~',
|
||
highlightSelector: '.btn',
|
||
positionOffsetY: '20%',
|
||
audioSrc: getPublicPath('audio/py_1_004.MP3')
|
||
}
|
||
]
|
||
|
||
const handleHomeGuideComplete = () => {
|
||
activeActionGuide.value = null
|
||
}
|
||
|
||
const handleUnlockedHomeGuideComplete = () => {
|
||
activeActionGuide.value = null
|
||
}
|
||
|
||
const handleElementGuideComplete = () => {
|
||
activeActionGuide.value = null
|
||
basicElements.markGuideShown(actionGuideKey)
|
||
}
|
||
|
||
const router = useRouter()
|
||
const user = useUserStore()
|
||
const film = useFilmStore()
|
||
const vlog = useVlogStore()
|
||
const interview = useInterviewStore()
|
||
const basicElements = useBasicElementsStore()
|
||
|
||
// 响应式状态
|
||
const guideShow = ref(false)
|
||
const selectedModule = ref<'vlog' | 'film' | 'interview'>('vlog')
|
||
const isImageLoading = ref(false)
|
||
|
||
// 预加载图片映射
|
||
const imageMap = {
|
||
vlog: IMAGE_PATHS.ACTION.VIDEO1,
|
||
film: IMAGE_PATHS.ACTION.VIDEO2,
|
||
interview: IMAGE_PATHS.ACTION.VIDEO3
|
||
}
|
||
|
||
//获取新手导航引导状态
|
||
const isGuideNewUser = computed(() => basicElements.isGuideNewUser)
|
||
const hasFinishedActionGuide = computed(() => basicElements.guideShown[actionGuideKey] === true)
|
||
|
||
// 预加载所有图片
|
||
const preloadImages = () => {
|
||
const imageUrls = Object.values(imageMap).map(path => getBackgroundImage(path))
|
||
imageUrls.forEach(url => {
|
||
const img = new Image()
|
||
img.src = url
|
||
})
|
||
}
|
||
|
||
// 事件处理函数
|
||
const handleOpenGuide = () => {
|
||
guideShow.value = true
|
||
if (activeActionGuide.value !== 'home' && isGuideNewUser.value && !hasFinishedActionGuide.value) {
|
||
activeActionGuide.value = 'unlocked'
|
||
}
|
||
}
|
||
|
||
const handleCloseGuide = () => {
|
||
guideShow.value = false
|
||
if (isGuideNewUser.value) {
|
||
basicElements.setIsGuideNewUser(false)
|
||
}
|
||
}
|
||
|
||
const handleModuleChange = (module: 'vlog' | 'film' | 'interview') => {
|
||
if (selectedModule.value === module) return
|
||
|
||
isImageLoading.value = true
|
||
selectedModule.value = module
|
||
|
||
// 模拟图片加载完成
|
||
setTimeout(() => {
|
||
isImageLoading.value = false
|
||
}, 100)
|
||
}
|
||
|
||
const handleClick = () => {
|
||
// if (selectedModule.value === 'interview') return
|
||
// console.log(selectedModule.value.charAt(0).toUpperCase() + selectedModule.value.slice(1))
|
||
router.push({
|
||
//首字母大写
|
||
name: selectedModule.value.charAt(0).toUpperCase() + selectedModule.value.slice(1)
|
||
})
|
||
}
|
||
|
||
// 计算属性
|
||
const selectedImage = computed(() => {
|
||
return getBackgroundImage(imageMap[selectedModule.value])
|
||
})
|
||
|
||
const isSkillDisabled = computed(() => !hasFinishedActionGuide.value)
|
||
|
||
const handleOpenStudyProgress = () => {
|
||
studyProgressVisible.value = true
|
||
}
|
||
|
||
const handleSkill = () => {
|
||
if (isSkillDisabled.value) return
|
||
|
||
router.push({
|
||
name: 'Skill'
|
||
})
|
||
}
|
||
|
||
// 清除所有与课程相关的缓存
|
||
const handleClearCache = () => {
|
||
film.resetFilmState()
|
||
vlog.resetVlogState()
|
||
interview.resetInterviewState()
|
||
basicElements.resetBasicElementsState()
|
||
user.resetUserState()
|
||
}
|
||
|
||
// 新手导航数据
|
||
const nodes: GuideNode[] = [
|
||
{
|
||
id: 1,
|
||
num: '1',
|
||
label: '三大基础要素',
|
||
x: 20,
|
||
y: 30,
|
||
hasChildren: true,
|
||
children: [
|
||
{ id: 1, label: '构图', icon: 'home/action/新手导航构图', routeName: 'Composition' },
|
||
{ id: 2, label: '光', icon: 'home/action/新手导航光', routeName: 'Light' },
|
||
{ id: 3, label: '运镜', icon: 'home/action/新手导航运镜', routeName: 'CameraMovement' }
|
||
]
|
||
},
|
||
{
|
||
id: 2,
|
||
num: '2',
|
||
label: '基础拍摄技巧',
|
||
x: 50,
|
||
y: 60,
|
||
hasChildren: false,
|
||
routeName: 'BasicShootingSkills'
|
||
},
|
||
{
|
||
id: 3,
|
||
num: '3',
|
||
label: '短视频剪辑技巧',
|
||
x: 80,
|
||
y: 30,
|
||
hasChildren: false,
|
||
routeName: 'ShortVideoEditingSkills'
|
||
}
|
||
]
|
||
|
||
// 显示子节点的节点ID
|
||
const showChildrenNodeId = ref<number | null>(null)
|
||
|
||
// 处理节点点击
|
||
const handleNodeClick = (node: GuideNode, event?: MouseEvent) => {
|
||
event?.stopPropagation()
|
||
|
||
if (!node.hasChildren) {
|
||
if (node.routeName) {
|
||
router.push({ name: node.routeName })
|
||
}
|
||
return
|
||
}
|
||
|
||
if (showChildrenNodeId.value === node.id) {
|
||
showChildrenNodeId.value = null
|
||
return
|
||
}
|
||
|
||
showChildrenNodeId.value = node.id
|
||
if (activeActionGuide.value !== 'unlocked' && !hasFinishedActionGuide.value) {
|
||
activeActionGuide.value = 'element'
|
||
}
|
||
}
|
||
|
||
// 处理子节点点击
|
||
const handleChildNodeClick = (childNode: GuideChildNode, event: MouseEvent) => {
|
||
event.stopPropagation()
|
||
router.push({ name: childNode.routeName })
|
||
}
|
||
|
||
// 点击外部关闭子节点
|
||
const handleClickOutside = () => {
|
||
showChildrenNodeId.value = null
|
||
}
|
||
|
||
// 生命周期
|
||
onMounted(() => {
|
||
preloadImages()
|
||
if (isGuideNewUser.value && !hasFinishedActionGuide.value) {
|
||
activeActionGuide.value = 'home'
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="w-full h-full relative">
|
||
<GuideSteps
|
||
v-if="activeActionGuide === 'home'"
|
||
:steps="homeGuideSteps"
|
||
position-offset-y="82%"
|
||
@complete="handleHomeGuideComplete"
|
||
/>
|
||
<GuideSteps
|
||
v-if="activeActionGuide === 'unlocked'"
|
||
:steps="unlockedHomeGuideSteps"
|
||
position-offset-y="82%"
|
||
@complete="handleUnlockedHomeGuideComplete"
|
||
/>
|
||
<GuideSteps
|
||
v-if="activeActionGuide === 'element'"
|
||
:steps="elementGuideSteps"
|
||
position-offset-y="82%"
|
||
@complete="handleElementGuideComplete"
|
||
/>
|
||
|
||
<!-- 背景图片 -->
|
||
<div
|
||
:class="['absolute inset-0 bg-cover bg-center bg-no-repeat']"
|
||
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.ACTION_BG) }"
|
||
></div>
|
||
|
||
<!-- 学习进度 -->
|
||
<div class="absolute top-0 left-0">
|
||
<ImgButton image="home/action/学习进度入口" @click="handleOpenStudyProgress" />
|
||
</div>
|
||
|
||
<StudyProgressPopup v-model="studyProgressVisible" />
|
||
|
||
<div
|
||
class="absolute top-4 left-4 flex items-center text-white text-xl bg-black/10 px-4 py-2 rounded-xl"
|
||
>
|
||
!测试阶段友情提示:课程全部完成后,若需重新观看之前的内容,请按
|
||
<img
|
||
class="w-10 cursor-pointer mx-2"
|
||
src="@/assets/svg/clear.svg"
|
||
@click="handleClearCache"
|
||
/>
|
||
清空缓存。
|
||
</div>
|
||
|
||
<!-- 选择模块 -->
|
||
<div :class="['absolute left-0 top-[-140px] inset-0 z-[8] pointer-events-none']">
|
||
<div
|
||
class="module-switcher-guide absolute w-[1380px] h-[600px] top-[460px] left-[calc(50%-690px)] pointer-events-none"
|
||
></div>
|
||
<ModuleSwitcher v-model="selectedModule" @change="handleModuleChange" />
|
||
</div>
|
||
|
||
<!-- 电视机 -->
|
||
<div
|
||
class="absolute w-[765px] h-[768px] right-0 top-0 z-[5] transition-all duration-300 hover:scale-105 hover:brightness-110 cursor-pointer tvGuide"
|
||
:class="{ 'opacity-50': isImageLoading }"
|
||
:style="{ backgroundImage: selectedImage }"
|
||
@click.stop="handleClick"
|
||
></div>
|
||
|
||
<!-- 技能树 按钮 -->
|
||
<ImgButton
|
||
@click="handleSkill"
|
||
:image="IMAGE_PATHS.ACTION.SKILLS_BTN"
|
||
:disabled="isSkillDisabled"
|
||
class="absolute bottom-0 right-0 z-30 skill-tree"
|
||
/>
|
||
|
||
<!-- 新手指导遮罩 -->
|
||
<div
|
||
v-if="isGuideNewUser && !hasFinishedActionGuide"
|
||
:class="[
|
||
'absolute inset-0 z-10',
|
||
activeActionGuide === 'home' ? 'bg-black/0' : 'bg-black/50'
|
||
]"
|
||
></div>
|
||
|
||
<!-- 新手指导 -->
|
||
<div
|
||
class="absolute bottom-0 left-0 w-full h-[497px] bg-cover bg-center bg-no-repeat z-20 pointer-events-none"
|
||
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.ACTION.FOOTER_BTN) }"
|
||
v-if="!guideShow"
|
||
>
|
||
<div
|
||
class="absolute bottom-20 right-[calc(50%-20px)] translate-x-1/2 w-20 h-20 cursor-pointer pointer-events-auto newUserGuide"
|
||
@click="handleOpenGuide"
|
||
></div>
|
||
</div>
|
||
|
||
<div
|
||
class="absolute bottom-0 left-0 w-full h-[911px] bg-cover bg-center bg-no-repeat z-40 flex flex-col items-center justify-center pt-40"
|
||
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.ACTION.FOOTER_BG) }"
|
||
v-else
|
||
>
|
||
<ImgButton
|
||
:image="IMAGE_PATHS.ACTION.DOWN"
|
||
class="absolute top-60 right-1/2 translate-x-1/2 z-50"
|
||
@click="handleCloseGuide"
|
||
/>
|
||
|
||
<!-- 新手导航内容 -->
|
||
<div class="relative w-[880px] h-[400px] mt-20">
|
||
<div class="w-full h-full relative guide-content" @click="handleClickOutside">
|
||
<!-- 标题区域 -->
|
||
<div class="text-center mt-10 mb-8">
|
||
<h2 class="text-5xl font-bold mb-3 text-[#7bfffd] tracking-wider drop-shadow-lg">
|
||
新手导航
|
||
</h2>
|
||
<p class="text-4xl text-white font-bold drop-shadow-md">点击节点即可开始学习</p>
|
||
</div>
|
||
|
||
<!-- 节点图表 -->
|
||
<div class="absolute inset-0 top-[60px] btn-items">
|
||
<!-- 连线 (SVG) -->
|
||
<svg class="absolute inset-0 w-full h-full pointer-events-none">
|
||
<!-- 左侧装饰线 -> 节点1 -->
|
||
<line
|
||
x1="0%"
|
||
y1="45%"
|
||
:x2="nodes[0].x + '%'"
|
||
:y2="nodes[0].y + '%'"
|
||
stroke="#7bfffd"
|
||
stroke-width="5"
|
||
stroke-linecap="round"
|
||
/>
|
||
<!-- 节点1 -> 节点2 -->
|
||
<line
|
||
:x1="nodes[0].x + '%'"
|
||
:y1="nodes[0].y + '%'"
|
||
:x2="nodes[1].x + '%'"
|
||
:y2="nodes[1].y + '%'"
|
||
stroke="#7bfffd"
|
||
stroke-width="5"
|
||
stroke-linecap="round"
|
||
/>
|
||
<!-- 节点2 -> 节点3 -->
|
||
<line
|
||
:x1="nodes[1].x + '%'"
|
||
:y1="nodes[1].y + '%'"
|
||
:x2="nodes[2].x + '%'"
|
||
:y2="nodes[2].y + '%'"
|
||
stroke="#7bfffd"
|
||
stroke-width="5"
|
||
stroke-linecap="round"
|
||
/>
|
||
<!-- 节点3 -> 右侧装饰线 -->
|
||
<line
|
||
:x1="nodes[2].x + '%'"
|
||
:y1="nodes[2].y + '%'"
|
||
x2="100%"
|
||
y2="45%"
|
||
stroke="#7bfffd"
|
||
stroke-width="5"
|
||
stroke-linecap="round"
|
||
/>
|
||
</svg>
|
||
|
||
<!-- 节点 -->
|
||
<div
|
||
v-for="node in nodes"
|
||
:key="node.id"
|
||
class="absolute transform -translate-x-1/2 -translate-y-1/2"
|
||
:style="{ left: node.x + '%', top: node.y + '%' }"
|
||
@click="handleNodeClick(node, $event)"
|
||
>
|
||
<div
|
||
class="cursor-pointer flex flex-col items-center group transition-all duration-300 hover:scale-110"
|
||
>
|
||
<!-- 圆圈 -->
|
||
<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="
|
||
showChildrenNodeId === node.id
|
||
? 'bg-[#ff4d8c] shadow-[0_0_20px_rgba(255,77,140,0.6)]'
|
||
: 'bg-[#60c8db] shadow-[0_0_20px_rgba(96, 200, 219,0.6)]'
|
||
"
|
||
>
|
||
{{ node.num }}
|
||
<!-- 选中状态的光晕 -->
|
||
<div
|
||
v-if="showChildrenNodeId === node.id"
|
||
class="absolute inset-0 rounded-full border-4 border-white/20 animate-pulse"
|
||
></div>
|
||
</div>
|
||
<!-- 标签 -->
|
||
<div
|
||
class="mt-2 text-4xl font-bold text-white tracking-wider drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)] whitespace-nowrap"
|
||
>
|
||
{{ node.label }}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 子节点图标(当点击三大基础要素时显示) -->
|
||
<div
|
||
v-if="node.hasChildren && showChildrenNodeId === node.id"
|
||
class="absolute -top-40 left-1/2 transform -translate-x-1/2 flex z-20 btn"
|
||
>
|
||
<div
|
||
v-for="childNode in node.children"
|
||
:key="childNode.id"
|
||
class="flex flex-col items-center cursor-pointer"
|
||
@click.stop="handleChildNodeClick(childNode, $event)"
|
||
>
|
||
<!-- 图标 -->
|
||
<div
|
||
class="w-36 flex items-center justify-center transition-all duration-300 hover:scale-110"
|
||
>
|
||
<img :src="getStaticImage(childNode.icon)" :alt="childNode.label" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|