Files
super_video/src/views/home/action.vue
T
zhongyidianandClaude Opus 4.7 6f8252472f feat: 完善模块引导与学习进度体验
统一引导提示的持久化控制,补充学习进度入口与模块资源,避免用户返回页面时重复触发提示。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:44:59 +08:00

520 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>