feat: 完善模块引导与学习进度体验

统一引导提示的持久化控制,补充学习进度入口与模块资源,避免用户返回页面时重复触发提示。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
zhongyidianandClaude Opus 4.7 committed 2026-05-08 09:44:59 +08:00
1 parent fc9633f371
commit 6f8252472f
114 files changed
+2758 -1798

No files matched your search

+36 -19
View File
@@ -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
}
+9 -2
View File
@@ -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 = () => {