forked from zhongyidian/super_video
feat: 完善课程学习与技能树体验
完善课程资源、学习进度与技能树解锁体验,统一视频播放与引导相关交互。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
1 parent
6f289959be
commit
3e258b87d9
314 files changed
+7020
-3386
No files matched your search
+242
-55
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user