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

+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>