forked from zhongyidian/super_video
feat: 完善模块引导与学习进度体验
统一引导提示的持久化控制,补充学习进度入口与模块资源,避免用户返回页面时重复触发提示。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
fc9633f371
commit
6f8252472f
114 files changed
+2758
-1798
No files matched your search
+36
-19
@@ -5,6 +5,7 @@ defineOptions({
|
||||
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 {
|
||||
@@ -35,10 +36,9 @@ type GuideNode = {
|
||||
children?: GuideChildNode[]
|
||||
}
|
||||
|
||||
const homeGuideShow = ref(true)
|
||||
const unlockedHomeGuideShow = ref(false)
|
||||
const elementGuideShow = ref(false)
|
||||
const unlockedHomeGuideCompleted = ref(false)
|
||||
const actionGuideKey = 'home-action-guide'
|
||||
const activeActionGuide = ref<'home' | 'unlocked' | 'element' | null>(null)
|
||||
const studyProgressVisible = ref(false)
|
||||
|
||||
const homeGuideSteps: GuideStep[] = [
|
||||
{
|
||||
@@ -109,16 +109,16 @@ const elementGuideSteps: GuideStep[] = [
|
||||
]
|
||||
|
||||
const handleHomeGuideComplete = () => {
|
||||
homeGuideShow.value = false
|
||||
activeActionGuide.value = null
|
||||
}
|
||||
|
||||
const handleUnlockedHomeGuideComplete = () => {
|
||||
unlockedHomeGuideShow.value = false
|
||||
activeActionGuide.value = null
|
||||
}
|
||||
|
||||
const handleElementGuideComplete = () => {
|
||||
elementGuideShow.value = false
|
||||
unlockedHomeGuideCompleted.value = true
|
||||
activeActionGuide.value = null
|
||||
basicElements.markGuideShown(actionGuideKey)
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
@@ -142,6 +142,7 @@ const imageMap = {
|
||||
|
||||
//获取新手导航引导状态
|
||||
const isGuideNewUser = computed(() => basicElements.isGuideNewUser)
|
||||
const hasFinishedActionGuide = computed(() => basicElements.guideShown[actionGuideKey] === true)
|
||||
|
||||
// 预加载所有图片
|
||||
const preloadImages = () => {
|
||||
@@ -155,8 +156,8 @@ const preloadImages = () => {
|
||||
// 事件处理函数
|
||||
const handleOpenGuide = () => {
|
||||
guideShow.value = true
|
||||
if (!homeGuideShow.value && isGuideNewUser.value) {
|
||||
unlockedHomeGuideShow.value = true
|
||||
if (activeActionGuide.value !== 'home' && isGuideNewUser.value && !hasFinishedActionGuide.value) {
|
||||
activeActionGuide.value = 'unlocked'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,8 +194,11 @@ const selectedImage = computed(() => {
|
||||
return getBackgroundImage(imageMap[selectedModule.value])
|
||||
})
|
||||
|
||||
const hasFinishedAllHomeGuides = computed(() => unlockedHomeGuideCompleted.value)
|
||||
const isSkillDisabled = computed(() => !hasFinishedAllHomeGuides.value)
|
||||
const isSkillDisabled = computed(() => !hasFinishedActionGuide.value)
|
||||
|
||||
const handleOpenStudyProgress = () => {
|
||||
studyProgressVisible.value = true
|
||||
}
|
||||
|
||||
const handleSkill = () => {
|
||||
if (isSkillDisabled.value) return
|
||||
@@ -268,8 +272,8 @@ const handleNodeClick = (node: GuideNode, event?: MouseEvent) => {
|
||||
}
|
||||
|
||||
showChildrenNodeId.value = node.id
|
||||
if (!unlockedHomeGuideShow.value && !unlockedHomeGuideCompleted.value) {
|
||||
elementGuideShow.value = true
|
||||
if (activeActionGuide.value !== 'unlocked' && !hasFinishedActionGuide.value) {
|
||||
activeActionGuide.value = 'element'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,25 +291,28 @@ const handleClickOutside = () => {
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
preloadImages()
|
||||
if (isGuideNewUser.value && !hasFinishedActionGuide.value) {
|
||||
activeActionGuide.value = 'home'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full h-full relative">
|
||||
<GuideSteps
|
||||
v-if="isGuideNewUser && homeGuideShow"
|
||||
v-if="activeActionGuide === 'home'"
|
||||
:steps="homeGuideSteps"
|
||||
position-offset-y="82%"
|
||||
@complete="handleHomeGuideComplete"
|
||||
/>
|
||||
<GuideSteps
|
||||
v-if="unlockedHomeGuideShow"
|
||||
v-if="activeActionGuide === 'unlocked'"
|
||||
:steps="unlockedHomeGuideSteps"
|
||||
position-offset-y="82%"
|
||||
@complete="handleUnlockedHomeGuideComplete"
|
||||
/>
|
||||
<GuideSteps
|
||||
v-if="elementGuideShow"
|
||||
v-if="activeActionGuide === 'element'"
|
||||
:steps="elementGuideSteps"
|
||||
position-offset-y="82%"
|
||||
@complete="handleElementGuideComplete"
|
||||
@@ -317,6 +324,13 @@ onMounted(() => {
|
||||
: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"
|
||||
>
|
||||
@@ -355,8 +369,11 @@ onMounted(() => {
|
||||
|
||||
<!-- 新手指导遮罩 -->
|
||||
<div
|
||||
v-if="isGuideNewUser"
|
||||
:class="['absolute inset-0 z-10', homeGuideShow ? 'bg-black/0' : 'bg-black/50']"
|
||||
v-if="isGuideNewUser && !hasFinishedActionGuide"
|
||||
:class="[
|
||||
'absolute inset-0 z-10',
|
||||
activeActionGuide === 'home' ? 'bg-black/0' : 'bg-black/50'
|
||||
]"
|
||||
></div>
|
||||
|
||||
<!-- 新手指导 -->
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { getBackgroundImage } from '@/utils'
|
||||
import { useBasicElementsStore, useFilmStore, useInterviewStore, useVlogStore } from '@/stores'
|
||||
import { AnchoredProgressBar, ImgButton, type ProgressAnchor } from '@/components'
|
||||
import { studyModuleAnchors, type StudyProgressModuleKey } from './studyProgressConfig'
|
||||
import { getProductionModuleProgress } from './studyProgressUtils'
|
||||
|
||||
defineOptions({
|
||||
name: 'StudyProgressPopup'
|
||||
})
|
||||
|
||||
type StudyProgressItem = {
|
||||
key: StudyProgressModuleKey
|
||||
progress: number
|
||||
anchors: ProgressAnchor[]
|
||||
}
|
||||
|
||||
const visible = defineModel<boolean>({ default: false })
|
||||
|
||||
const basicElements = useBasicElementsStore()
|
||||
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 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(() =>
|
||||
basicElements.isBasicElementsUnlock ? 100 : basicElements.basicElementsUnlockProgress
|
||||
)
|
||||
const totalProgress = computed(() => {
|
||||
const total =
|
||||
basicProgress.value + vlog.progressPercent + interview.progressPercent + film.progressPercent
|
||||
return Math.round(total / 4)
|
||||
})
|
||||
const progressItems = computed<StudyProgressItem[]>(() => [
|
||||
{
|
||||
key: 'basic',
|
||||
progress: basicProgress.value,
|
||||
anchors: studyModuleAnchors.basic
|
||||
},
|
||||
{
|
||||
key: 'vlog',
|
||||
progress: getProductionModuleProgress('vlog', vlog.viewed),
|
||||
anchors: studyModuleAnchors.vlog
|
||||
},
|
||||
{
|
||||
key: 'interview',
|
||||
progress: getProductionModuleProgress('interview', interview.viewed),
|
||||
anchors: studyModuleAnchors.interview
|
||||
},
|
||||
{
|
||||
key: 'film',
|
||||
progress: getProductionModuleProgress('film', film.viewed),
|
||||
anchors: studyModuleAnchors.film
|
||||
}
|
||||
])
|
||||
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 scale-95 blur-[8px]"
|
||||
enter-to-class="opacity-100 scale-100 blur-none"
|
||||
leave-active-class="transition-all duration-300 ease-in"
|
||||
leave-from-class="opacity-100 scale-100 blur-none"
|
||||
leave-to-class="opacity-0 scale-95 blur-[8px]"
|
||||
>
|
||||
<div
|
||||
v-if="visible"
|
||||
class="fixed inset-0 z-[100] bg-cover bg-center bg-no-repeat"
|
||||
:style="{ backgroundImage: getBackgroundImage('home/stuy/学习进度底图') }"
|
||||
>
|
||||
<ImgButton
|
||||
class="absolute right-0 top-0 z-10"
|
||||
@click="handleClose"
|
||||
:image="'components/close'"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="absolute left-1/2 top-[14%] flex -translate-x-1/2 items-end text-white font-[1000] tracking-widest pr-10"
|
||||
:style="{ textShadow: totalTextShadow }"
|
||||
>
|
||||
<span class="text-[clamp(36px,4.5vw,72px)] leading-none">总进度</span>
|
||||
<span class="text-[clamp(36px,4.5vw,72px)] leading-none">{{ totalProgress }}%</span>
|
||||
</div>
|
||||
|
||||
<div class="w-[1580px] mx-auto mt-[215px] flex flex-col pr-16 pl-64 gap-2">
|
||||
<AnchoredProgressBar
|
||||
v-for="item in progressItems"
|
||||
:key="item.key"
|
||||
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 }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ProgressAnchor } 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 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
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { moduleStepMap } from '@/config/moduleStepMap'
|
||||
|
||||
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 interpolate = (start: number, end: number, progress: number) =>
|
||||
start + (end - start) * progress
|
||||
|
||||
export const getProductionModuleProgress = (
|
||||
moduleKey: ProductionModuleKey,
|
||||
viewed: Record<string, boolean>
|
||||
) => {
|
||||
const stepRoutes = moduleStepMap[moduleKey]
|
||||
const hasAnyViewed = Object.values(stepRoutes).some(routes =>
|
||||
routes.some(routeName => viewed[routeName])
|
||||
)
|
||||
|
||||
if (!hasAnyViewed) return 0
|
||||
|
||||
const stepProgress = (step: StepKey) => getStepProgress(stepRoutes[step], viewed)
|
||||
const step1Progress = stepProgress(1)
|
||||
const step2Progress = stepProgress(2)
|
||||
const step3Progress = stepProgress(3)
|
||||
|
||||
if (step1Progress < 1) return Math.round(interpolate(20, 42, step1Progress))
|
||||
if (step2Progress < 1) return Math.round(interpolate(42, 64, step2Progress))
|
||||
if (step3Progress < 1) return Math.round(interpolate(64, 85, step3Progress))
|
||||
|
||||
return 100
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { getBackgroundImage, getPublicPath, getStaticImage, IMAGE_PATHS } from '
|
||||
import { ImgButton } from '@/components/Button'
|
||||
import { GuideSteps, ImagePreview } from '@/components'
|
||||
import { useScale } from '@/composables/useScale'
|
||||
import { useBasicElementsStore } from '@/stores/modules/basic-elements'
|
||||
|
||||
const CourseFrameGuide = () => (
|
||||
<div class="flex items-center justify-start">
|
||||
@@ -64,11 +65,17 @@ export default defineComponent({
|
||||
positionOffsetY: '40%'
|
||||
}
|
||||
])
|
||||
const showGuide = ref(true)
|
||||
const basicElementsStore = useBasicElementsStore()
|
||||
const guideKey = 'home-intro'
|
||||
const showGuide = ref(!basicElementsStore.guideShown[guideKey])
|
||||
const { scale, offsetX, offsetY } = useScale()
|
||||
|
||||
if (showGuide.value) {
|
||||
basicElementsStore.markGuideShown(guideKey)
|
||||
}
|
||||
|
||||
const handleComplete = () => {
|
||||
console.log('handleComplete')
|
||||
showGuide.value = false
|
||||
}
|
||||
|
||||
const openMindMap = () => {
|
||||
|
||||
Reference in new issue
Block a user