feat: 完善实战进度与技能树联动

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zhongyidianandClaude Opus 4.8 committed 2026-06-23 10:15:57 +08:00
1 parent 3e258b87d9
commit dd233e102b
194 files changed
+2915 -1481

No files matched your search

@@ -9,12 +9,7 @@ import { Video } from '@/components'
import QuestionQuiz, {
type Question,
} from '@/components/QuestionQuiz/QuestionQuiz.vue'
import {
getPublicPath,
getStaticImage,
IMAGE_PATHS,
getVideoPoster,
} from '@/utils'
import { getPublicPath, IMAGE_PATHS, getVideoPoster } from '@/utils'
import { useBasicElementsStore } from '@/stores/modules/basic-elements'
const router = useRouter()
@@ -310,13 +305,13 @@ onBeforeRouteLeave((to) => {
/>
<!-- 步骤导航按钮 -->
<button
class="absolute right-4 top-1/2 z-10 -translate-y-1/2 w-12 h-12 bg-blue-500/80 hover:bg-blue-500 rounded-full flex items-center justify-center text-white transition-all duration-200"
class="absolute right-4 top-1/2 z-10 -translate-y-1/2 w-16 h-16 bg-blue-500/80 hover:bg-blue-500 rounded-full flex items-center justify-center text-white transition-all duration-200"
@click="showStepNavigation = true"
title="步骤导航"
>
<svg
width="20"
height="20"
width="35"
height="35"
viewBox="0 0 20 20"
fill="none"
>
@@ -5,12 +5,7 @@ import ImgButton from '@/components/Button/ImgButton.vue'
import StepNavigation from '@/components/Guide/StepNavigation.vue'
import UnfinishedLearningPrompt from '@/components/UnfinishedLearningPrompt'
import { Video } from '@/components'
import {
getPublicPath,
getStaticImage,
IMAGE_PATHS,
getVideoPoster,
} from '@/utils'
import { getPublicPath, IMAGE_PATHS, getVideoPoster } from '@/utils'
// 运镜初识 - 暂时用运镜的基本方式代替
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { useBasicElementsStore } from '@/stores/modules/basic-elements'
@@ -135,8 +130,6 @@ const handleClose = () => {
router.back()
}
const label = computed(() => props.label as string)
const handlePlay = () => {}
const onEnded = () => {
@@ -184,13 +177,13 @@ onBeforeRouteLeave((to) => {
/>
<!-- 步骤导航按钮 -->
<button
class="absolute right-4 top-1/2 z-10 -translate-y-1/2 w-12 h-12 bg-blue-500/80 hover:bg-blue-500 rounded-full flex items-center justify-center text-white transition-all duration-200"
class="absolute right-4 top-1/2 z-10 -translate-y-1/2 w-16 h-16 bg-blue-500/80 hover:bg-blue-500 rounded-full flex items-center justify-center text-white transition-all duration-200"
@click="showStepNavigation = true"
title="步骤导航"
>
<svg
width="20"
height="20"
width="35"
height="35"
viewBox="0 0 20 20"
fill="none"
>
@@ -156,13 +156,13 @@ onMounted(() => {})
/>
<!-- 步骤导航按钮 -->
<button
class="absolute right-4 top-1/2 z-10 -translate-y-1/2 w-12 h-12 bg-blue-500/80 hover:bg-blue-500 rounded-full flex items-center justify-center text-white transition-all duration-200 step-navigation-btn"
class="absolute right-4 top-1/2 z-10 -translate-y-1/2 w-16 h-16 bg-blue-500/80 hover:bg-blue-500 rounded-full flex items-center justify-center text-white transition-all duration-200 step-navigation-btn"
@click="showStepNavigation = true"
title="步骤导航"
>
<svg
width="20"
height="20"
width="35"
height="35"
viewBox="0 0 20 20"
fill="none"
>
+4 -9
View File
@@ -8,12 +8,7 @@ import { Video } from '@/components'
import QuestionQuiz, {
type Question,
} from '@/components/QuestionQuiz/QuestionQuiz.vue'
import {
getPublicPath,
getStaticImage,
IMAGE_PATHS,
getVideoPoster,
} from '@/utils'
import { getPublicPath, IMAGE_PATHS, getVideoPoster } from '@/utils'
import { GuideSteps } from '@/components/Guide'
import GuideStepsItem from '@/components/Guide/GuideStepsItem.vue'
import { useBasicElementsStore } from '@/stores/modules/basic-elements'
@@ -243,13 +238,13 @@ onBeforeRouteLeave((to) => {
/>
<!-- 步骤导航按钮 -->
<button
class="absolute right-4 top-1/2 z-10 -translate-y-1/2 w-12 h-12 bg-blue-500/80 hover:bg-blue-500 rounded-full flex items-center justify-center text-white transition-all duration-200"
class="absolute right-4 top-1/2 z-10 -translate-y-1/2 w-16 h-16 bg-blue-500/80 hover:bg-blue-500 rounded-full flex items-center justify-center text-white transition-all duration-200"
@click="showStepNavigation = true"
title="步骤导航"
>
<svg
width="20"
height="20"
width="35"
height="35"
viewBox="0 0 20 20"
fill="none"
>
@@ -219,13 +219,13 @@ onBeforeRouteLeave((to) => {
/>
<!-- 步骤导航按钮 -->
<button
class="absolute right-4 top-1/2 z-10 -translate-y-1/2 w-12 h-12 bg-blue-500/80 hover:bg-blue-500 rounded-full flex items-center justify-center text-white transition-all duration-200"
class="absolute right-4 top-1/2 z-10 -translate-y-1/2 w-16 h-16 bg-blue-500/80 hover:bg-blue-500 rounded-full flex items-center justify-center text-white transition-all duration-200"
@click="showStepNavigation = true"
title="步骤导航"
>
<svg
width="20"
height="20"
width="35"
height="35"
viewBox="0 0 20 20"
fill="none"
>
@@ -21,11 +21,10 @@ const handleBack = () => {
router.back()
}
// 声音转场视频 URL(暂时用其他视频代替)
// 声音转场视频 URL
const soundTransitionVideoUrl = getPublicPath(
'video/film/editing/xianchangpaishe_shengyingzhuanchang1.mp4',
)
// const soundTransitionVideoUrl2 = getPublicPath('video/on-site/xianchangpaishe_yanshipaishe.mp4')
const soundTransitionVideoUrl3 = getPublicPath(
'video/film/editing/xianchangpaishe_shengyingzhuanchang3.mp4',
)
@@ -78,6 +77,7 @@ const handleQuestionsComplete = () => {
showQuiz.value = false
showVerification.value = true
film.markEditingVideoWatched('soundTransition')
film.markViewed('FilmEditingSoundTransition')
}
</script>
@@ -3,12 +3,7 @@ import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { Video } from '@/components'
import {
getBackgroundImage,
getPublicPath,
IMAGE_PATHS,
getVideoPoster,
} from '@/utils'
import { getPublicPath, IMAGE_PATHS, getVideoPoster } from '@/utils'
import { useFilmStore } from '@/stores'
const router = useRouter()
@@ -20,7 +15,7 @@ const handleBack = () => {
// 连续剪辑视频 URL(暂时用其他视频代替)
const continuityVideoUrl = getPublicPath(
'video/on-site/dygmk_jjcnlks_batch.mp4',
'video/film/editing/dygmk_jjcnlks_batch.mp4',
)
// 视频播放状态
+68 -1
View File
@@ -7,16 +7,28 @@ import {
} from '@/utils'
import { ImgButton } from '@/components/Button'
import { computed, onMounted, ref, watch, type Ref } from 'vue'
import { computed, nextTick, onMounted, ref, watch, type Ref } from 'vue'
import { ProgressBar, Overlay } from '@/components'
import { useRoute, useRouter } from 'vue-router'
import { useFilmStore } from '@/stores'
import { GuideSteps, GuideStepsItem, type GuideStep } from '@/components/Guide'
import {
ModuleMedalPopup,
useModuleMedalPopup,
} from '@/views/home/components/ModuleMedalPopup'
import { StudyProgressPopup } from '@/views/home/components/StudyProgressPopup'
import FilmMenu from './components/menu.vue'
const route = useRoute()
const router = useRouter()
const film = useFilmStore()
const {
activeMedalKey,
visible: medalPopupVisible,
showMedalIfNeeded,
closeMedal,
} = useModuleMedalPopup()
const pendingFilmMedal = ref(false)
const visible = computed({
get: () => film.filmMenuVisible,
@@ -42,6 +54,32 @@ const showTaskGuide = computed(
const showPracticeGuide = computed(
() => currentStep.value === 4 && !hasShownPracticeGuide.value,
)
const isFilmCompleted = computed(() => progress.value >= 100)
const showPendingFilmMedal = () => {
if (!pendingFilmMedal.value) return
if (
showReturnGuide.value ||
stageGuideStep.value ||
showPracticeGuide.value ||
showTaskGuide.value
) {
return
}
pendingFilmMedal.value = false
showMedalIfNeeded('film')
}
const handleCloseMedalPopup = () => {
closeMedal()
}
const studyProgressVisible = ref(false)
const handleViewProgressFromMedal = () => {
closeMedal()
studyProgressVisible.value = true
}
// 关闭学习进度弹窗后默认返回首页
watch(studyProgressVisible, (value) => {
if (!value) router.push({ name: 'Action' })
})
// 思路提点完成回到首页的一次性引导
const showReturnGuide = ref(
@@ -186,6 +224,26 @@ watch(
},
{ immediate: true },
)
watch(isFilmCompleted, (completed, previousCompleted) => {
if (!completed || previousCompleted !== false) return
pendingFilmMedal.value = true
nextTick(showPendingFilmMedal)
})
watch(
[showReturnGuide, stageGuideStep, showPracticeGuide, showTaskGuide],
() => {
nextTick(showPendingFilmMedal)
},
)
onMounted(() => {
if (isFilmCompleted.value) {
pendingFilmMedal.value = true
}
showPendingFilmMedal()
})
</script>
<template>
@@ -353,5 +411,14 @@ watch(
</div>
</GuideStepsItem>
</GuideSteps> -->
<ModuleMedalPopup
:visible="medalPopupVisible"
:module-key="activeMedalKey"
@close="handleCloseMedalPopup"
@view-progress="handleViewProgressFromMedal"
/>
<StudyProgressPopup v-model="studyProgressVisible" />
</div>
</template>
@@ -12,7 +12,7 @@ export function useViewfinderA() {
// 缩放级别 (保留用于点击按钮)
const zoomLevels = [3, 2, 1]
const currentZoom = ref(1)
const currentZoom = ref(2)
const isCapturing = ref(false) // 拍照闪光效果
// 拖拽相关状态
@@ -44,8 +44,14 @@ export function useViewfinderA() {
const maxOffsetY = (scaledHeight - containerHeight) / 2
// 限制偏移量
currentOffsetX.value = Math.max(-maxOffsetX, Math.min(maxOffsetX, currentOffsetX.value))
currentOffsetY.value = Math.max(-maxOffsetY, Math.min(maxOffsetY, currentOffsetY.value))
currentOffsetX.value = Math.max(
-maxOffsetX,
Math.min(maxOffsetX, currentOffsetX.value),
)
currentOffsetY.value = Math.max(
-maxOffsetY,
Math.min(maxOffsetY, currentOffsetY.value),
)
}
// 设置缩放级别
@@ -144,6 +150,6 @@ export function useViewfinderA() {
onDrag,
endDrag,
handleWheel,
getTransform
getTransform,
}
}
+3 -1
View File
@@ -111,6 +111,7 @@ interface WorkItem {
title: string
stars: number
path: string
watchKey: string
description: string
}
@@ -121,6 +122,7 @@ const worksList = ref<WorkItem[]>([
title: '学生案例',
stars: 4,
path: '/video/film/practice/film_anli1.mp4',
watchKey: 'film-practice-case-1',
description: '27084 施颖雯 杨可馨 胡悦莹 陆佳慧 雷鑫丽',
},
])
@@ -375,7 +377,7 @@ onUnmounted(() => {
<ImgButton
:class="index === 0 ? 'view-btn' : ''"
image="vlog/practice/查看"
:path="`/film/practice/video/${encodeURIComponent(work.path)}`"
:path="`/film/practice/video/${encodeURIComponent(work.path)}?watchKey=${work.watchKey}`"
/>
</div>
</div>
+12 -3
View File
@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { Video } from '@/components'
import { ImgButton } from '@/components/Button'
import PracticeRatingModal, {
@@ -10,6 +10,7 @@ import { computed, ref } from 'vue'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { useFilmStore } from '@/stores/modules/film'
const router = useRouter()
const route = useRoute()
const filmStore = useFilmStore()
const store = filmStore
const guideKey = 'film-practice-video-rating'
@@ -82,6 +83,13 @@ const handleSaveEvaluation = (items: PracticeRatingItem[]): void => {
store.setPracticeWorkRating(workKey.value, toScores(items))
}
const handleVideoEnded = (): void => {
const watchKey = route.query.watchKey
if (typeof watchKey === 'string' && watchKey) {
filmStore.markPracticeVideoWatched(watchKey)
}
}
/** 保存弹窗 */
const showModal = ref<boolean>(false)
@@ -99,7 +107,7 @@ const handleBack = (): void => {
@click="handleBack"
/>
<ImgButton
class="absolute right-0 top-1/2 -translate-y-1/2 z-50"
class="btn-1 absolute right-0 top-1/2 -translate-y-1/2 z-50"
image="vlog/practice/评分"
@click="showModal = true"
/>
@@ -110,6 +118,7 @@ const handleBack = (): void => {
:muted="false"
:controls="true"
:loop="false"
@ended="handleVideoEnded"
/>
<PracticeRatingModal
@@ -124,7 +133,7 @@ const handleBack = (): void => {
<GuideSteps
:visible="showGuide"
highlight-selector=".btn-1"
position-offset-y="60%"
position-offset-y="85%"
@complete="handleRatingGuideComplete"
>
<GuideStepsItem audio-src="/audio/py_6_008.MP3">
+13 -9
View File
@@ -2,6 +2,7 @@
import { computed, nextTick, onMounted, ref } from 'vue'
import { onBeforeRouteLeave, useRouter, useRoute } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue'
import DownloadButton from '@/components/DownloadButton.vue'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import StepNavigation from '@/components/Guide/StepNavigation.vue'
import UnfinishedLearningPrompt from '@/components/UnfinishedLearningPrompt'
@@ -250,18 +251,16 @@ const onEnded = () => {
}
if (key.value === 'profession') {
markCurrentProfessionVideoWatched()
// 检查是否是"虚实"(index 2)或"分镜头"(index 4)
// 虚实(index 2)和分镜头(index 4)需要答题完成后才计入学习完成
if (currentVideoIndex.value === 2 || currentVideoIndex.value === 4) {
// 暂停播放并显示答题窗口
playing.value = false
questions.value = getQuestionsForVideo(currentVideoIndex.value)
showQuiz.value = true
} else {
// 其他视频正常切换
handleVideoChange()
return
}
markCurrentProfessionVideoWatched()
handleVideoChange()
}
}
@@ -292,6 +291,7 @@ const handleStepNavigationClose = () => {
// 答题完成后的处理
const handleQuestionsComplete = () => {
markCurrentProfessionVideoWatched()
showQuiz.value = false
// 答题完成后继续播放下一个视频
handleVideoChange()
@@ -351,7 +351,7 @@ onMounted(() => {
<!-- 步骤导航按钮 -->
<button
v-if="key === 'profession'"
class="step-navigation-btn absolute right-4 top-1/2 z-10 -translate-y-1/2 w-20 h-20 bg-blue-500/80 hover:bg-blue-500 rounded-full flex items-center justify-center text-white transition-all duration-200"
class="step-navigation-btn absolute right-4 top-1/2 z-10 -translate-y-1/2 w-16 h-16 bg-blue-500/80 hover:bg-blue-500 rounded-full flex items-center justify-center text-white transition-all duration-200"
@click="showStepNavigation = true"
title="步骤导航"
>
@@ -399,13 +399,17 @@ onMounted(() => {
<div
v-else-if="key === 'idea'"
:src="image"
class="cursor-pointer"
class="relative cursor-pointer"
@click="handleIdeaImageClick"
>
<img
:src="ideaImage"
alt=""
/>
<DownloadButton
:image-url="ideaImage"
file-name="电影感思路提点.png"
/>
</div>
</div>
@@ -51,6 +51,7 @@ const items = computed(() => [
activeImage: 'work/选中_带三角',
inactiveImage: 'work/未选中_带三角',
statusImage: getStatusImage('case'),
completed: film.workVideosWatched.case,
},
{
key: 'profession',
@@ -58,6 +59,7 @@ const items = computed(() => [
activeImage: 'work/选中_带三角',
inactiveImage: 'work/未选中_带三角',
statusImage: getStatusImage('profession'),
completed: film.workVideosWatched.profession,
},
{
key: 'idea',
@@ -65,6 +67,7 @@ const items = computed(() => [
activeImage: 'work/选中_带三角',
inactiveImage: 'work/未选中_带三角',
statusImage: getStatusImage('idea'),
completed: film.workVideosWatched.idea,
},
])
+101 -478
View File
@@ -2,17 +2,17 @@
defineOptions({
name: 'Action',
})
import {
getBackgroundImage,
getPublicPath,
IMAGE_PATHS,
getStaticImage,
} from '@/utils'
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button'
import { ModuleSwitcher } from './components/ModuleSwitcher'
import StudyProgressPopup from './components/StudyProgressPopup.vue'
import { computed, ref, onMounted, onActivated, nextTick } from 'vue'
import { useRouter, type RouteRecordName } from 'vue-router'
import { NewbieNavigation } from './components/NewbieNavigation'
import {
ModuleMedalPopup,
useModuleMedalPopup,
} from './components/ModuleMedalPopup'
import { StudyProgressPopup } from './components/StudyProgressPopup'
import { computed, ref, onMounted, onActivated, nextTick, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
useUserStore,
useFilmStore,
@@ -26,51 +26,19 @@ import {
getBasicElementsProgress,
getOverallStudyProgress,
getProductionModuleProgress,
} from './components/studyProgressUtils'
} from '@/config/studyProgress'
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 = {
id: number
num: string
label: string
x: number
y: number
hasChildren: boolean
routeName?: RouteRecordName
children?: GuideChildNode[]
videoKeys: readonly string[]
}
const actionGuideKey = 'home-action-guide'
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 isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
const activeActionGuide = ref<ActionGuideKey | null>(null)
const pendingElementGuide = ref(false)
const studyProgressVisible = ref(false)
@@ -98,37 +66,6 @@ const homeGuideSteps: GuideStep[] = [
},
]
const unlockedHomeGuideSteps: GuideStep[] = [
{
id: 'home-guide-welcome',
content: '欢迎来到新手模块!在这里,我会带着你解锁短视频创作的核心基本功!',
audioSrc: getPublicPath('audio/py_1_001.MP3'),
},
{
id: 'home-guide-map',
content:
'我们会读懂构图、用好光线、灵活运镜,了解基础拍摄技巧和剪映软件的基本操作。',
audioSrc: getPublicPath('audio/py_1_002.MP3'),
},
{
id: 'home-guide-start-node',
content: '选择任意节点开始学习!',
highlightSelector: '.guide-content',
positionOffsetY: '20%',
audioSrc: getPublicPath('audio/py_1_003.MP3'),
},
]
const elementGuideSteps: GuideStep[] = [
{
id: 'home-guide-start-element',
highlightSelector: '.child-node-buttons',
content: '选择任意要素开始学习!',
positionOffsetY: '20%',
audioSrc: getPublicPath('audio/py_1_004.MP3'),
},
]
const completionGuideSteps: GuideStep[] = [
{
id: 'home-guide-completion',
@@ -160,64 +97,56 @@ const modulesGuideSteps: GuideStep[] = [
},
]
const actionGuideStepsMap = {
home: homeGuideSteps,
unlocked: unlockedHomeGuideSteps,
element: elementGuideSteps,
completion: completionGuideSteps,
modules: modulesGuideSteps,
}
type ActionGuideKey = 'home' | 'unlocked' | 'element' | 'completion' | 'modules'
const startActionGuide = (guide: keyof typeof actionGuideStepsMap) => {
activeGuideStepHasHighlight.value = Boolean(
actionGuideStepsMap[guide][0]?.highlightSelector,
)
const startActionGuide = (guide: ActionGuideKey) => {
activeActionGuide.value = guide
}
const handleGuideStepChange = (step: GuideStep | null) => {
activeGuideStepHasHighlight.value = Boolean(step?.highlightSelector)
}
const handleHomeGuideComplete = () => {
activeActionGuide.value = null
activeGuideStepHasHighlight.value = false
basicElements.markGuideShown(homeGuideKey)
nextTick(showPendingBasicMedal)
}
const handleUnlockedHomeGuideComplete = () => {
const handlePanelGuideComplete = (key: 'unlocked' | 'element') => {
activeActionGuide.value = null
activeGuideStepHasHighlight.value = false
basicElements.markUnlockedGuideFinished()
}
const handleElementGuideComplete = () => {
activeActionGuide.value = null
activeGuideStepHasHighlight.value = false
basicElements.markGuideShown(actionGuideKey)
if (key === 'unlocked') {
basicElements.markUnlockedGuideFinished()
} else {
basicElements.markGuideShown(actionGuideKey)
}
nextTick(showPendingBasicMedal)
}
const handleCompletionGuideComplete = () => {
activeActionGuide.value = null
activeGuideStepHasHighlight.value = false
basicElements.markGuideShown(completionGuideKey)
nextTick(showPendingBasicMedal)
}
const handleModulesGuideComplete = () => {
activeActionGuide.value = null
activeGuideStepHasHighlight.value = false
basicElements.markGuideShown(modulesGuideKey)
nextTick(showPendingBasicMedal)
}
const route = useRoute()
const router = useRouter()
const user = useUserStore()
const film = useFilmStore()
const vlog = useVlogStore()
const interview = useInterviewStore()
const basicElements = useBasicElementsStore()
const {
activeMedalKey,
visible: medalPopupVisible,
showMedalIfNeeded,
closeMedal,
} = useModuleMedalPopup()
const pendingBasicMedal = ref(false)
// 响应式状态
const guideShow = ref(false)
const selectedModule = ref<'vlog' | 'film' | 'interview'>('vlog')
const isImageLoading = ref(false)
@@ -255,11 +184,23 @@ const isAllBasicElementsCompleted = computed(() =>
(key) => basicElements.learningVideosWatched[key] === true,
),
)
const shouldShowGuideMask = computed(
() => !isAllBasicElementsCompleted.value || guideShow.value,
)
const shouldMakeGuideMaskTransparent = computed(
() => activeGuideStepHasHighlight.value && !guideShow.value,
const showPendingBasicMedal = () => {
if (!pendingBasicMedal.value) return
if (route.name !== 'Action') return
if (activeActionGuide.value !== null) return
pendingBasicMedal.value = false
showMedalIfNeeded('basic')
}
const handleCloseMedalPopup = () => {
closeMedal()
}
const handleViewProgressFromMedal = () => {
closeMedal()
studyProgressVisible.value = true
}
// 基础要素未完成时的守门员遮罩:阻止进入模块
const shouldShowBasicElementsMask = computed(
() => !isAllBasicElementsCompleted.value,
)
// 预加载所有图片
@@ -275,14 +216,12 @@ const preloadImages = () => {
// 事件处理函数
const handleOpenGuide = () => {
guideShow.value = true
if (activeActionGuide.value === null && !hasFinishedUnlockedGuide.value) {
startActionGuide('unlocked')
}
}
const handleCloseGuide = () => {
guideShow.value = false
if (isGuideNewUser.value) {
basicElements.setIsGuideNewUser(false)
}
@@ -325,11 +264,6 @@ const selectedImage = computed(() => {
return getBackgroundImage(imageMap[selectedModule.value])
})
const filmViewed = computed(() => ({
...film.viewed,
FilmOnsiteShooting: film.allOnsiteScenesViewed,
FilmEditing: film.allEditingVideosWatched,
}))
const totalStudyPercent = computed(() =>
getOverallStudyProgress([
getBasicElementsProgress(
@@ -341,17 +275,15 @@ const totalStudyPercent = computed(() =>
'vlog',
vlog.isRouteViewed,
vlog.workVideosWatched,
vlog.practiceProgress,
),
getProductionModuleProgress(
'interview',
interview.isRouteViewed,
interview.workVideosWatched,
interview.practiceProgress,
),
getProductionModuleProgress(
'film',
filmViewed.value,
film.workVideosWatched,
),
film.progressPercent,
]),
)
@@ -374,92 +306,8 @@ const handleClearCache = () => {
user.resetUserState()
}
// 新手导航数据
const nodes: GuideNode[] = [
{
id: 1,
num: '1',
label: '三大基础要素',
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,
},
],
},
{
id: 2,
num: '2',
label: '基础拍摄技巧',
x: 50,
y: 60,
hasChildren: false,
routeName: 'BasicShootingSkills',
videoKeys: basicElementsShootingVideoKeys,
},
{
id: 3,
num: '3',
label: '短视频剪辑技巧',
x: 80,
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)
// 处理节点点击
const handleNodeClick = (node: GuideNode, event?: MouseEvent) => {
event?.stopPropagation()
if (!node.hasChildren) {
showChildrenNodeId.value = null
if (node.routeName) {
router.push({ name: node.routeName })
}
return
}
if (showChildrenNodeId.value === node.id) {
showChildrenNodeId.value = null
return
}
showChildrenNodeId.value = node.id
// 新手导航面板:展开「有子节点」节点时,按条件预约元素引导
const onExpandChildren = () => {
if (activeActionGuide.value !== 'unlocked' && !hasFinishedActionGuide.value) {
pendingElementGuide.value = true
}
@@ -471,17 +319,6 @@ const handleChildNodeAfterEnter = () => {
startActionGuide('element')
}
// 处理子节点点击
const handleChildNodeClick = (childNode: GuideChildNode, event: MouseEvent) => {
event.stopPropagation()
router.push({ name: childNode.routeName })
}
// 点击外部关闭子节点
const handleClickOutside = () => {
showChildrenNodeId.value = null
}
const handleReturnHome = () => {
router.push('/')
}
@@ -491,7 +328,16 @@ onMounted(() => {
preloadImages()
})
watch(isAllBasicElementsCompleted, (completed, previousCompleted) => {
if (!completed || previousCompleted !== false) return
pendingBasicMedal.value = true
nextTick(showPendingBasicMedal)
})
onActivated(() => {
if (isAllBasicElementsCompleted.value) {
pendingBasicMedal.value = true
}
if (activeActionGuide.value !== null) return
if (isAllBasicElementsCompleted.value && !hasFinishedCompletionGuide.value) {
startActionGuide('completion')
@@ -499,7 +345,9 @@ onActivated(() => {
}
if (!hasFinishedHomeGuide.value && !hasFinishedActionGuide.value) {
startActionGuide('home')
return
}
nextTick(showPendingBasicMedal)
})
</script>
@@ -510,35 +358,18 @@ onActivated(() => {
:steps="homeGuideSteps"
position-offset-y="82%"
@close="handleHomeGuideComplete"
@step-change="handleGuideStepChange"
/>
<GuideSteps
v-if="activeActionGuide === 'unlocked'"
:steps="unlockedHomeGuideSteps"
position-offset-y="82%"
@close="handleUnlockedHomeGuideComplete"
@step-change="handleGuideStepChange"
/>
<GuideSteps
v-if="activeActionGuide === 'element'"
:steps="elementGuideSteps"
position-offset-y="82%"
@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"
/>
<!-- 返回首页按钮 -->
@@ -555,21 +386,33 @@ onActivated(() => {
></div>
<!-- 学习进度 -->
<div class="absolute top-0 left-0">
<ImgButton
image="home/action/学习进度入口"
@click="handleOpenStudyProgress"
/>
<div
class="absolute top-0 left-0 z-40 w-[382px] h-[304px] transition-all hover:scale-105 hover:brightness-110 cursor-pointer"
:style="{
backgroundImage: getBackgroundImage('home/action/学习进度入口'),
}"
@click="handleOpenStudyProgress"
>
<div
class="absolute top-[4.2rem] left-[150px] flex h-[150px] w-[150px] items-center justify-center overflow-visible pointer-events-none"
:style="{
transform: `rotate(-14deg) skewX(${isSafari ? -6 : -18}deg)`,
}"
>
<span class="whitespace-nowrap text-5xl italic text-white">
{{ totalStudyPercent }}%
</span>
</div>
</div>
<StudyProgressPopup v-model="studyProgressVisible" />
<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>
<ModuleMedalPopup
:visible="medalPopupVisible"
:module-key="activeMedalKey"
@close="handleCloseMedalPopup"
@view-progress="handleViewProgressFromMedal"
/>
<!-- <div
class="absolute top-4 left-4 flex items-center text-white text-xl bg-black/10 px-4 py-2 rounded-xl"
@@ -613,242 +456,22 @@ onActivated(() => {
class="absolute bottom-0 right-0 z-50 skill-tree"
/>
<!-- 新手导航遮罩:未完成提示词时阻止进入模块;完成后打开导航时突出导航区域 -->
<!-- 基础要素未完成时的守门员遮罩:阻止进入模块 -->
<div
v-if="shouldShowGuideMask"
:class="[
'absolute inset-0 z-10',
shouldMakeGuideMaskTransparent ? 'bg-black/0' : 'bg-black/50',
]"
v-if="shouldShowBasicElementsMask"
class="absolute inset-0 z-10"
:style="{ backgroundImage: getBackgroundImage('home/action/首页锁') }"
></div>
<!-- 新手指导 -->
<div
class="absolute bottom-0 left-0 w-full h-[497px] bg-cover bg-center bg-no-repeat z-20 pointer-events-none"
:style="{
backgroundImage: getBackgroundImage(IMAGE_PATHS.ACTION.FOOTER_BTN),
}"
v-if="!guideShow"
>
<div
class="absolute bottom-20 right-[calc(50%-20px)] translate-x-1/2 w-20 h-20 cursor-pointer pointer-events-auto newUserGuide"
@click="handleOpenGuide"
></div>
</div>
<div
class="absolute bottom-0 left-0 w-full h-[911px] bg-cover bg-center bg-no-repeat z-40 flex flex-col items-center justify-center pt-40"
:style="{
backgroundImage: getBackgroundImage(IMAGE_PATHS.ACTION.FOOTER_BG),
}"
v-else
>
<ImgButton
:image="IMAGE_PATHS.ACTION.DOWN"
class="absolute top-60 right-1/2 translate-x-1/2 z-50"
@click="handleCloseGuide"
/>
<!-- 新手导航内容 -->
<div class="relative w-[880px] h-[400px] mt-20">
<div
class="w-full h-full relative guide-content"
@click="handleClickOutside"
>
<!-- 标题区域 -->
<div
class="text-center mt-10 mb-8 transition-opacity duration-300"
:class="showChildrenNodeId !== null ? 'opacity-35' : 'opacity-100'"
>
<h2
class="text-5xl font-bold mb-3 text-[#7bfffd] tracking-wider drop-shadow-lg"
>
新手导航
</h2>
<p class="text-4xl text-white font-bold drop-shadow-md">
点击节点即可开始学习
</p>
</div>
<!-- 节点图表 -->
<div class="absolute inset-0 top-[60px] btn-items">
<!-- 连线 (SVG) -->
<svg class="absolute inset-0 w-full h-full pointer-events-none">
<!-- 左侧装饰线 -> 节点1 -->
<line
x1="0%"
y1="45%"
:x2="nodes[0].x + '%'"
:y2="nodes[0].y + '%'"
stroke="#7bfffd"
stroke-width="5"
stroke-linecap="round"
/>
<!-- 节点1 -> 节点2 -->
<line
:x1="nodes[0].x + '%'"
:y1="nodes[0].y + '%'"
:x2="nodes[1].x + '%'"
:y2="nodes[1].y + '%'"
stroke="#7bfffd"
stroke-width="5"
stroke-linecap="round"
class="transition-opacity duration-300"
:class="
showChildrenNodeId !== null ? 'opacity-35' : 'opacity-100'
"
/>
<!-- 节点2 -> 节点3 -->
<line
:x1="nodes[1].x + '%'"
:y1="nodes[1].y + '%'"
:x2="nodes[2].x + '%'"
:y2="nodes[2].y + '%'"
stroke="#7bfffd"
stroke-width="5"
stroke-linecap="round"
class="transition-opacity duration-300"
:class="
showChildrenNodeId !== null ? 'opacity-35' : 'opacity-100'
"
/>
<!-- 节点3 -> 右侧装饰线 -->
<line
:x1="nodes[2].x + '%'"
:y1="nodes[2].y + '%'"
x2="100%"
y2="45%"
stroke="#7bfffd"
stroke-width="5"
stroke-linecap="round"
class="transition-opacity duration-300"
:class="
showChildrenNodeId !== null ? 'opacity-35' : 'opacity-100'
"
/>
</svg>
<!-- 节点 -->
<div
v-for="node in nodes"
:key="node.id"
class="absolute transform -translate-x-1/2 -translate-y-1/2 transition-opacity duration-300"
:class="
showChildrenNodeId !== null && showChildrenNodeId !== node.id
? 'opacity-35'
: 'opacity-100'
"
:style="{ left: node.x + '%', top: node.y + '%' }"
@click="handleNodeClick(node, $event)"
>
<div
class="cursor-pointer flex flex-col items-center group transition-all duration-300 hover:scale-110"
>
<!-- 圆圈 -->
<div
class="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)]'
: 'bg-[#60c8db] shadow-[0_0_20px_rgba(96, 200, 219,0.6)]'
"
>
{{ node.num }}
<!-- 选中状态的光晕 -->
<div
v-if="showChildrenNodeId === node.id"
class="absolute inset-0 rounded-full border-4 border-white/20 animate-pulse"
></div>
<!-- 完成状态指示 -->
<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
class="mt-2 text-4xl font-bold text-white tracking-wider drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)] whitespace-nowrap"
>
{{ node.label }}
</div>
</div>
<!-- 子节点图标(当点击三大基础要素时显示) -->
<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"
>
<div
class="relative flex rounded-[30px] bg-[rgba(78,195,217,1)] backdrop-filter px-3 py-2 shadow-[0_0_0_2px_rgba(250,250,250,1),0_0_20px_rgba(121,252,249,1)] after:absolute after:left-1/2 after:top-[calc(100%-9px)] after:h-5 after:w-5 after:-translate-x-1/2 after:rotate-45 after:border-r-2 after:border-b-2 after:border-white after:bg-[rgba(78,195,217,1)]"
>
<div
v-for="childNode in node.children"
:key="childNode.id"
class="group/child relative z-10 flex flex-col items-center cursor-pointer"
@click.stop="handleChildNodeClick(childNode, $event)"
>
<!-- 图标 -->
<div
class="relative w-36 flex items-center justify-center transition-all duration-300 hover:scale-110"
>
<div
class="absolute inset-x-5 inset-y-4 rounded-full bg-[#7bfffd]/12 blur-xl opacity-60 transition-opacity duration-300 group-hover/child:opacity-100"
></div>
<img
class="relative z-10 drop-shadow-[0_0_10px_rgba(123,255,253,0.45)] transition-all duration-300 group-hover/child:brightness-125 group-hover/child:drop-shadow-[0_0_18px_rgba(123,255,253,0.8)]"
: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>
</div>
</Transition>
</div>
</div>
</div>
</div>
</div>
<NewbieNavigation
:active-guide="activeActionGuide"
@open-panel="handleOpenGuide"
@close-panel="handleCloseGuide"
@expand-children="onExpandChildren"
@children-after-enter="handleChildNodeAfterEnter"
@guide-complete="handlePanelGuideComplete"
/>
</div>
</template>
<style scoped>
.child-node-pop-enter-active,
.child-node-pop-leave-active {
transition:
opacity 0.22s ease,
transform 0.22s cubic-bezier(0.2, 0.9, 0.2, 1.1);
transform-origin: bottom center;
}
.child-node-pop-enter-from,
.child-node-pop-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(10px) scale(0.86);
}
.child-node-pop-enter-to,
.child-node-pop-leave-from {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1);
}
</style>
<style scoped></style>
@@ -0,0 +1,232 @@
<script lang="ts" setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { ImgButton, Overlay } from '@/components'
import { getStaticImage } from '@/utils'
import { moduleMedalConfig, type MedalModuleKey } from './moduleMedalConfig'
defineOptions({
name: 'ModuleMedalPopup',
})
const props = defineProps<{
visible: boolean
moduleKey: MedalModuleKey | null
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'view-progress'): void
}>()
const phase = ref<'medal' | 'content'>('medal')
const shouldRenderContent = ref(false)
const phaseTimer = ref<number>()
const currentConfig = computed(() =>
props.moduleKey ? moduleMedalConfig[props.moduleKey] : null,
)
const titleSrc = computed(() =>
currentConfig.value ? getStaticImage(currentConfig.value.titleImage) : '',
)
const medalSrc = computed(() =>
currentConfig.value ? getStaticImage(currentConfig.value.medalImage) : '',
)
const clearPhaseTimer = () => {
window.clearTimeout(phaseTimer.value)
phaseTimer.value = undefined
}
const startAnimation = async () => {
clearPhaseTimer()
phase.value = 'medal'
shouldRenderContent.value = false
await nextTick()
phaseTimer.value = window.setTimeout(() => {
phase.value = 'content'
shouldRenderContent.value = true
}, 850)
}
watch(
() => props.visible,
(visible) => {
if (visible) {
startAnimation()
return
}
clearPhaseTimer()
phase.value = 'medal'
shouldRenderContent.value = false
},
)
onBeforeUnmount(clearPhaseTimer)
const handleClose = () => {
emit('close')
}
const handleViewProgress = () => {
emit('view-progress')
}
</script>
<template>
<Overlay
:visible="visible && moduleKey !== null"
animation="fade"
:duration="260"
:clickable="false"
background="transparent"
:z-index="3000"
content-class="w-full h-full"
>
<div class="relative h-full w-full overflow-hidden">
<img
class="absolute inset-0 h-full w-full object-cover transition-opacity duration-500"
:class="shouldRenderContent ? 'opacity-100' : 'opacity-0'"
:src="getStaticImage('components/medal/半透明背景')"
alt=""
/>
<div class="absolute inset-0 flex flex-col items-center">
<img
v-if="titleSrc"
class="mt-[60px] max-h-[155px] max-w-[1210px] object-contain transition-all duration-500"
:class="
shouldRenderContent
? 'translate-y-0 opacity-100'
: '-translate-y-5 opacity-0'
"
:src="titleSrc"
alt=""
/>
<div
class="relative mt-[36px] flex h-[560px] w-[680px] items-center justify-center"
>
<div
class="medal-ring pointer-events-none absolute h-[520px] w-[520px] rounded-full"
></div>
<div
class="medal-aura pointer-events-none absolute h-[470px] w-[470px] rounded-full transition-opacity duration-500"
:class="shouldRenderContent ? 'opacity-100' : 'opacity-80'"
></div>
<img
v-if="medalSrc"
:key="moduleKey ?? 'none'"
class="medal-image relative z-10 max-h-[560px] max-w-[640px] object-contain"
:class="phase === 'content' ? 'medal-breathe' : 'medal-pop'"
:src="medalSrc"
alt=""
/>
</div>
<img
class="mt-[-6px] object-contain transition-all delay-150 duration-500"
:class="
shouldRenderContent
? 'translate-y-0 opacity-100'
: 'translate-y-5 opacity-0'
"
:src="getStaticImage('components/medal/收集后可在【我的进度】中查看')"
alt=""
/>
<div
class="mt-[26px] flex items-center gap-[160px] transition-all delay-200 duration-500"
:class="
shouldRenderContent
? 'translate-y-0 opacity-100'
: 'translate-y-5 opacity-0'
"
>
<ImgButton
:image="'components/medal/立即查看'"
@click="handleViewProgress"
/>
<ImgButton
:image="'components/medal/关闭'"
@click="handleClose"
/>
</div>
</div>
</div>
</Overlay>
</template>
<style scoped>
.medal-pop {
animation: medal-pop-in 850ms cubic-bezier(0.18, 0.9, 0.24, 1.18) both;
}
.medal-breathe {
animation: medal-breathe 1800ms ease-in-out infinite;
filter: drop-shadow(0 0 28px rgba(255, 246, 186, 0.78));
}
.medal-ring {
background: radial-gradient(
circle,
rgba(255, 246, 186, 0) 42%,
rgba(255, 246, 186, 0.85) 58%,
rgba(255, 246, 186, 0) 72%
);
animation: medal-ring 850ms ease-out both;
}
.medal-aura {
background: radial-gradient(
circle,
rgba(255, 246, 186, 0.52) 0%,
rgba(255, 215, 138, 0.28) 34%,
rgba(255, 215, 138, 0) 72%
);
filter: blur(6px);
}
@keyframes medal-pop-in {
0% {
opacity: 0;
transform: scale(0.2) rotate(-18deg);
filter: drop-shadow(0 0 0 rgba(255, 246, 186, 0));
}
58% {
opacity: 1;
transform: scale(1.16) rotate(3deg);
filter: drop-shadow(0 0 46px rgba(255, 246, 186, 0.95));
}
100% {
opacity: 1;
transform: scale(1) rotate(0deg);
filter: drop-shadow(0 0 26px rgba(255, 246, 186, 0.75));
}
}
@keyframes medal-ring {
0% {
opacity: 0;
transform: scale(0.35);
}
45% {
opacity: 0.85;
}
100% {
opacity: 0;
transform: scale(1.45);
}
}
@keyframes medal-breathe {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.035);
}
}
</style>
@@ -0,0 +1,3 @@
export { default as ModuleMedalPopup } from './ModuleMedalPopup.vue'
export * from './useModuleMedalPopup'
export * from './moduleMedalConfig'
@@ -0,0 +1,27 @@
export type MedalModuleKey = 'basic' | 'vlog' | 'interview' | 'film'
export const moduleMedalConfig = {
basic: {
label: '新手导航',
titleImage: 'components/medal/恭喜获得新手导航模块徽章',
medalImage: 'components/medal/新手导航徽章-弹',
},
vlog: {
label: 'Vlog',
titleImage: 'components/medal/恭喜获得vlog模块徽章',
medalImage: 'components/medal/vlog徽章-弹',
},
interview: {
label: '访谈',
titleImage: 'components/medal/恭喜获得访谈模块徽章',
medalImage: 'components/medal/访谈徽章-弹',
},
film: {
label: '电影',
titleImage: 'components/medal/恭喜获得电影模块徽章',
medalImage: 'components/medal/电影徽章-弹',
},
} satisfies Record<
MedalModuleKey,
{ label: string; titleImage: string; medalImage: string }
>
@@ -0,0 +1,49 @@
import { computed, ref } from 'vue'
import {
useBasicElementsStore,
useFilmStore,
useInterviewStore,
useVlogStore,
} from '@/stores'
import type { MedalModuleKey } from './moduleMedalConfig'
// 复用各模块 store 的 guideShown 记录徽章是否已展示:
// 它已随易班 userDefine 持久化,并在各 store reset 时清空。
const MEDAL_SHOWN_KEY = 'module-medal-popup'
// 当前展示的徽章 key(运行时 UI 状态,跨页面共享,无需持久化)
const activeMedalKey = ref<MedalModuleKey | null>(null)
export const useModuleMedalPopup = () => {
const storeMap = {
basic: useBasicElementsStore(),
vlog: useVlogStore(),
interview: useInterviewStore(),
film: useFilmStore(),
}
const visible = computed(() => activeMedalKey.value !== null)
const isMedalShown = (key: MedalModuleKey) =>
storeMap[key].guideShown[MEDAL_SHOWN_KEY] === true
const showMedalIfNeeded = (key: MedalModuleKey) => {
if (isMedalShown(key)) return
activeMedalKey.value = key
}
const closeMedal = () => {
const key = activeMedalKey.value
if (key) {
storeMap[key].markGuideShown(MEDAL_SHOWN_KEY)
}
activeMedalKey.value = null
}
return {
activeMedalKey,
visible,
showMedalIfNeeded,
closeMedal,
}
}
@@ -0,0 +1,379 @@
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { GuideSteps } from '@/components/Guide'
import type { GuideStep } from '@/components/Guide/types'
import { useBasicElementsStore } from '@/stores'
import {
getBackgroundImage,
getPublicPath,
getStaticImage,
IMAGE_PATHS,
} from '@/utils'
import {
navigationNodes,
type GuideChildNode,
type GuideNode,
} from './navigationNodes'
export type NewbieNavigationGuideKey = 'unlocked' | 'element'
defineOptions({
name: 'NewbieNavigation',
})
const props = defineProps<{
activeGuide: string | null
}>()
const emit = defineEmits<{
(e: 'open-panel'): void
(e: 'close-panel'): void
(e: 'expand-children'): void
(e: 'children-after-enter'): void
(e: 'guide-complete', key: NewbieNavigationGuideKey): void
}>()
const router = useRouter()
const basicElements = useBasicElementsStore()
const guideShow = ref(false)
const showChildrenNodeId = ref<number | null>(null)
const unlockedHomeGuideSteps: GuideStep[] = [
{
id: 'home-guide-welcome',
content: '欢迎来到新手模块!在这里,我会带着你解锁短视频创作的核心基本功!',
audioSrc: getPublicPath('audio/py_1_001.MP3'),
},
{
id: 'home-guide-map',
content:
'我们会读懂构图、用好光线、灵活运镜,了解基础拍摄技巧和剪映软件的基本操作。',
audioSrc: getPublicPath('audio/py_1_002.MP3'),
},
{
id: 'home-guide-start-node',
content: '选择任意节点开始学习!',
highlightSelector: '.guide-content',
positionOffsetY: '20%',
audioSrc: getPublicPath('audio/py_1_003.MP3'),
},
]
const elementGuideSteps: GuideStep[] = [
{
id: 'home-guide-start-element',
highlightSelector: '.child-node-buttons',
content: '选择任意要素开始学习!',
positionOffsetY: '20%',
audioSrc: getPublicPath('audio/py_1_004.MP3'),
},
]
const guideStatusIcons = {
done: getStaticImage('home/新手导航/已完成'),
undone: getStaticImage('home/新手导航/未完成'),
}
const shouldShowGuidePanelMask = computed(() => guideShow.value)
const isVideoKeysCompleted = (keys: readonly string[]) => {
if (keys.length === 0) return false
return keys.every((key) => basicElements.learningVideosWatched[key] === true)
}
const handleOpenGuide = () => {
guideShow.value = true
emit('open-panel')
}
const handleCloseGuide = () => {
guideShow.value = false
emit('close-panel')
}
const handleClickOutside = () => {
showChildrenNodeId.value = null
}
const handleNodeClick = (node: GuideNode, event?: MouseEvent) => {
event?.stopPropagation()
if (!node.hasChildren) {
showChildrenNodeId.value = null
if (node.routeName) {
router.push({ name: node.routeName })
}
return
}
if (showChildrenNodeId.value === node.id) {
showChildrenNodeId.value = null
return
}
showChildrenNodeId.value = node.id
emit('expand-children')
}
const handleChildNodeAfterEnter = () => {
emit('children-after-enter')
}
const handleChildNodeClick = (childNode: GuideChildNode, event: MouseEvent) => {
event.stopPropagation()
router.push({ name: childNode.routeName })
}
</script>
<template>
<!-- 新手导航面板打开时的背景遮罩:压暗背景突出导航区域 -->
<div
v-if="shouldShowGuidePanelMask"
class="absolute inset-0 z-10 bg-black/50"
></div>
<!-- 新手指导 -->
<div
v-if="!guideShow"
class="absolute bottom-0 left-0 w-full h-[497px] bg-cover bg-center bg-no-repeat z-20 pointer-events-none"
:style="{
backgroundImage: getBackgroundImage(IMAGE_PATHS.ACTION.FOOTER_BTN),
}"
>
<div
class="absolute bottom-20 right-[calc(50%-20px)] translate-x-1/2 w-20 h-20 cursor-pointer pointer-events-auto newUserGuide"
@click="handleOpenGuide"
></div>
</div>
<div
v-else
class="absolute bottom-0 left-0 w-full h-[911px] bg-cover bg-center bg-no-repeat z-40 flex flex-col items-center justify-center pt-40"
:style="{
backgroundImage: getBackgroundImage(IMAGE_PATHS.ACTION.FOOTER_BG),
}"
>
<ImgButton
:image="IMAGE_PATHS.ACTION.DOWN"
class="absolute top-60 right-1/2 translate-x-1/2 z-50"
@click="handleCloseGuide"
/>
<!-- 新手导航内容 -->
<div class="relative w-[880px] h-[400px] mt-20">
<div
class="w-full h-full relative guide-content"
@click="handleClickOutside"
>
<!-- 标题区域 -->
<div
class="text-center mt-10 mb-8 transition-opacity duration-300"
:class="showChildrenNodeId !== null ? 'opacity-35' : 'opacity-100'"
>
<h2
class="text-5xl font-bold mb-3 text-[#7bfffd] tracking-wider drop-shadow-lg"
>
新手导航
</h2>
<p class="text-4xl text-white font-bold drop-shadow-md">
点击节点即可开始学习
</p>
</div>
<!-- 节点图表 -->
<div class="absolute inset-0 top-[60px] btn-items">
<!-- 连线 (SVG) -->
<svg class="absolute inset-0 w-full h-full pointer-events-none">
<!-- 左侧装饰线 -> 节点1 -->
<line
x1="0%"
y1="45%"
:x2="navigationNodes[0].x + '%'"
:y2="navigationNodes[0].y + '%'"
stroke="#7bfffd"
stroke-width="5"
stroke-linecap="round"
/>
<!-- 节点1 -> 节点2 -->
<line
:x1="navigationNodes[0].x + '%'"
:y1="navigationNodes[0].y + '%'"
:x2="navigationNodes[1].x + '%'"
:y2="navigationNodes[1].y + '%'"
stroke="#7bfffd"
stroke-width="5"
stroke-linecap="round"
class="transition-opacity duration-300"
:class="
showChildrenNodeId !== null ? 'opacity-35' : 'opacity-100'
"
/>
<!-- 节点2 -> 节点3 -->
<line
:x1="navigationNodes[1].x + '%'"
:y1="navigationNodes[1].y + '%'"
:x2="navigationNodes[2].x + '%'"
:y2="navigationNodes[2].y + '%'"
stroke="#7bfffd"
stroke-width="5"
stroke-linecap="round"
class="transition-opacity duration-300"
:class="
showChildrenNodeId !== null ? 'opacity-35' : 'opacity-100'
"
/>
<!-- 节点3 -> 右侧装饰线 -->
<line
:x1="navigationNodes[2].x + '%'"
:y1="navigationNodes[2].y + '%'"
x2="100%"
y2="45%"
stroke="#7bfffd"
stroke-width="5"
stroke-linecap="round"
class="transition-opacity duration-300"
:class="
showChildrenNodeId !== null ? 'opacity-35' : 'opacity-100'
"
/>
</svg>
<!-- 节点 -->
<div
v-for="node in navigationNodes"
:key="node.id"
class="absolute transform -translate-x-1/2 -translate-y-1/2 transition-opacity duration-300"
:class="
showChildrenNodeId !== null && showChildrenNodeId !== node.id
? 'opacity-35'
: 'opacity-100'
"
:style="{ left: node.x + '%', top: node.y + '%' }"
@click="handleNodeClick(node, $event)"
>
<div
class="cursor-pointer flex flex-col items-center group transition-all duration-300 hover:scale-110"
>
<!-- 圆圈 -->
<div
class="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)]'
: 'bg-[#60c8db] shadow-[0_0_20px_rgba(96, 200, 219,0.6)]'
"
>
{{ node.num }}
<!-- 选中状态的光晕 -->
<div
v-if="showChildrenNodeId === node.id"
class="absolute inset-0 rounded-full border-4 border-white/20 animate-pulse"
></div>
<!-- 完成状态指示 -->
<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
class="mt-2 text-4xl font-bold text-white tracking-wider drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)] whitespace-nowrap"
>
{{ node.label }}
</div>
</div>
<!-- 子节点图标(当点击三大基础要素时显示) -->
<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"
>
<div
class="relative flex rounded-[30px] bg-[rgba(78,195,217,1)] backdrop-filter px-3 py-2 shadow-[0_0_0_2px_rgba(250,250,250,1),0_0_20px_rgba(121,252,249,1)] after:absolute after:left-1/2 after:top-[calc(100%-9px)] after:h-5 after:w-5 after:-translate-x-1/2 after:rotate-45 after:border-r-2 after:border-b-2 after:border-white after:bg-[rgba(78,195,217,1)]"
>
<div
v-for="childNode in node.children"
:key="childNode.id"
class="group/child relative z-10 flex flex-col items-center cursor-pointer"
@click.stop="handleChildNodeClick(childNode, $event)"
>
<!-- 图标 -->
<div
class="relative w-36 flex items-center justify-center transition-all duration-300 hover:scale-110"
>
<div
class="absolute inset-x-5 inset-y-4 rounded-full bg-[#7bfffd]/12 blur-xl opacity-60 transition-opacity duration-300 group-hover/child:opacity-100"
></div>
<img
class="relative z-10 drop-shadow-[0_0_10px_rgba(123,255,253,0.45)] transition-all duration-300 group-hover/child:brightness-125 group-hover/child:drop-shadow-[0_0_18px_rgba(123,255,253,0.8)]"
: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>
</div>
</Transition>
</div>
</div>
</div>
</div>
</div>
<GuideSteps
v-if="activeGuide === 'unlocked'"
:steps="unlockedHomeGuideSteps"
position-offset-y="82%"
@close="emit('guide-complete', 'unlocked')"
/>
<GuideSteps
v-if="activeGuide === 'element'"
:steps="elementGuideSteps"
position-offset-y="82%"
@close="emit('guide-complete', 'element')"
/>
</template>
<style scoped>
.child-node-pop-enter-active,
.child-node-pop-leave-active {
transition:
opacity 0.22s ease,
transform 0.22s cubic-bezier(0.2, 0.9, 0.2, 1.1);
transform-origin: bottom center;
}
.child-node-pop-enter-from,
.child-node-pop-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(10px) scale(0.86);
}
.child-node-pop-enter-to,
.child-node-pop-leave-from {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1);
}
</style>
@@ -0,0 +1,2 @@
export { default as NewbieNavigation } from './NewbieNavigation.vue'
export * from './navigationNodes'
@@ -0,0 +1,92 @@
import type { RouteRecordName } from 'vue-router'
import {
basicElementsFoundationVideoKeys,
basicElementsShootingVideoKeys,
basicElementsEditingVideoKeys,
} from '@/config/moduleStepMap'
export type GuideChildNode = {
id: number
label: string
icon: string
routeName: RouteRecordName
videoKeys: readonly string[]
}
export type GuideNode = {
id: number
num: string
label: string
x: number
y: number
hasChildren: boolean
routeName?: RouteRecordName
children?: GuideChildNode[]
videoKeys: readonly string[]
}
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:'),
)
// 新手导航节点配置
export const navigationNodes: GuideNode[] = [
{
id: 1,
num: '1',
label: '三大基础要素',
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,
},
],
},
{
id: 2,
num: '2',
label: '基础拍摄技巧',
x: 50,
y: 60,
hasChildren: false,
routeName: 'BasicShootingSkills',
videoKeys: basicElementsShootingVideoKeys,
},
{
id: 3,
num: '3',
label: '短视频剪辑技巧',
x: 80,
y: 30,
hasChildren: false,
routeName: 'ShortVideoEditingSkills',
videoKeys: basicElementsEditingVideoKeys,
},
]
@@ -1,347 +0,0 @@
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { getBackgroundImage, getStaticImage } from '@/utils'
import {
useBasicElementsStore,
useFilmStore,
useInterviewStore,
useVlogStore,
} from '@/stores'
import {
AnchoredProgressBar,
ImgButton,
computeSegmentLayout,
type ProgressSegment,
} from '@/components'
import {
studyProgressDisplayConfig,
type StudyProgressModuleKey,
} from './studyProgressConfig'
import {
getBasicElementsProgress,
getOverallStudyProgress,
getProductionModuleProgress,
} from './studyProgressUtils'
defineOptions({
name: 'StudyProgressPopup',
})
type ProductionModuleKey = Exclude<StudyProgressModuleKey, 'basic'>
type StudyProgressItem = {
key: StudyProgressModuleKey
progress: number
segments: ProgressSegment[]
detailSegments?: ProgressSegment[]
}
const visible = defineModel<boolean>({ default: false })
const basicElements = useBasicElementsStore()
const vlog = useVlogStore()
const interview = useInterviewStore()
const film = useFilmStore()
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 basicProgress = computed(() =>
getBasicElementsProgress(
basicElements.basicElementsUnlockProgress,
basicElements.isBasicElementsUnlock,
basicElements.learningVideosWatched,
),
)
const vlogProgress = computed(() =>
getProductionModuleProgress(
'vlog',
vlog.isRouteViewed,
vlog.workVideosWatched,
),
)
const interviewProgress = computed(() =>
getProductionModuleProgress(
'interview',
interview.isRouteViewed,
interview.workVideosWatched,
),
)
const filmViewed = computed(() => ({
...film.viewed,
FilmOnsiteShooting: film.allOnsiteScenesViewed,
FilmEditing: film.allEditingVideosWatched,
}))
const filmProgress = computed(() =>
getProductionModuleProgress('film', filmViewed.value, film.workVideosWatched),
)
const totalProgress = computed(() =>
getOverallStudyProgress([
basicProgress.value,
vlogProgress.value,
interviewProgress.value,
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[]>(() => [
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>
<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/学习进度底图') }"
@click="closeDetail"
>
<ImgButton
class="absolute right-0 top-0 z-10"
:image="'components/close'"
@click="handleClose"
/>
<div
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>
<span class="text-[clamp(36px,4.5vw,72px)] leading-none">
{{ totalProgress }}%
</span>
</div>
<div
class="mx-auto mt-[215px] flex w-[1580px] flex-col gap-2 pl-[254px] pr-[60px]"
@click.stop
>
<div
v-for="item in progressItems"
:key="item.key"
class="relative w-full"
: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>
</template>
@@ -0,0 +1,131 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { computeSegmentLayout } from './computeSegmentLayout'
import type { AnchoredProgressBarProps } from './types'
defineOptions({
name: 'AnchoredProgressBar',
})
const props = withDefaults(defineProps<AnchoredProgressBarProps>(), {
min: 0,
max: 100,
width: 1266,
height: 34,
dividerWidth: 8,
dividerHeight: 44,
segments: () => [],
trackClass: 'bg-black/20',
fillClass: 'bg-[#31effa] shadow-[0_0_26px_rgba(49,239,250,0.95)]',
segmentClass: '',
showDividers: true,
markerSrc: '',
markerSize: 78,
markerTop: -22,
showPercent: false,
percentTop: 44,
percentClass:
'whitespace-nowrap font-[1000] tracking-widest text-white text-[30px]',
percentTextShadow: '',
})
const clamp = (value: number, lower: number, upper: number) =>
Math.min(Math.max(value, lower), upper)
const progressPercent = computed(() => {
const range = props.max - props.min
if (range <= 0) return 0
return clamp(((props.progress - props.min) / range) * 100, 0, 100)
})
const segmentLayout = computed(() =>
computeSegmentLayout(props.segments, props.min, props.max),
)
const toPx = (percent: number) => Math.round((percent / 100) * props.width)
const segmentPixelLayout = computed(() =>
segmentLayout.value.map((segment) => ({
...segment,
leftPx: toPx(segment.left),
widthPx: toPx(segment.width),
dividerLeftPx: toPx(segment.left + segment.width),
})),
)
const fillWidth = computed(() => toPx(progressPercent.value))
const markerLeft = computed(() => toPx(progressPercent.value))
const displayProgress = computed(() =>
Math.round(clamp(props.progress, props.min, props.max)),
)
const dividerTop = computed(() => (props.height - props.dividerHeight) / 2)
</script>
<template>
<div
class="relative w-full"
:style="{ width: `${props.width}px`, height: `${props.height}px` }"
>
<div
class="absolute inset-0 overflow-hidden"
:class="trackClass"
>
<div
v-for="segment in segmentPixelLayout"
:key="segment.id"
class="absolute top-0 h-full"
:class="[segmentClass, segment.className]"
:style="{
left: `${segment.leftPx}px`,
width: `${segment.widthPx}px`,
...(segment.style ?? {}),
}"
></div>
<div
class="absolute left-0 top-0 h-full transition-all duration-700 ease-out"
:class="fillClass"
:style="{ width: `${fillWidth}px` }"
></div>
</div>
<template v-if="showDividers">
<div
v-for="segment in segmentPixelLayout.slice(0, -1)"
:key="`${segment.id}-divider`"
class="pointer-events-none absolute -translate-x-1/2 rounded-[3px] bg-white"
:style="{
left: `${segment.dividerLeftPx}px`,
top: `${dividerTop}px`,
height: `${props.dividerHeight}px`,
width: `${props.dividerWidth}px`,
}"
></div>
</template>
<img
v-if="markerSrc"
:src="markerSrc"
class="pointer-events-none absolute -translate-x-1/2 transition-all duration-700 ease-out"
:style="{
top: `${props.markerTop}px`,
left: `${markerLeft}px`,
height: `${props.markerSize}px`,
}"
alt=""
/>
<div
v-if="showPercent"
class="pointer-events-none absolute -translate-x-1/2 transition-all duration-700 ease-out"
:class="percentClass"
:style="{
top: `${props.percentTop}px`,
left: `${markerLeft}px`,
textShadow: percentTextShadow,
}"
>
{{ displayProgress }}%
</div>
<slot
:segments="segmentLayout"
:progress="progressPercent"
/>
</div>
</template>
@@ -0,0 +1,21 @@
import type { ProgressSegment, ProgressSegmentLayout } from './types'
const clamp = (value: number, lower: number, upper: number) =>
Math.min(Math.max(value, lower), upper)
export const computeSegmentLayout = (
segments: ProgressSegment[],
min = 0,
max = 100,
): ProgressSegmentLayout[] => {
const range = max - min
const toPercent = (value: number) =>
range <= 0 ? 0 : clamp(((value - min) / range) * 100, 0, 100)
return segments.map((segment) => {
const left = toPercent(segment.start)
const right = toPercent(segment.end)
const width = Math.max(0, right - left)
return { ...segment, left, width, center: left + width / 2 }
})
}
@@ -0,0 +1,3 @@
export { default as AnchoredProgressBar } from './AnchoredProgressBar.vue'
export { computeSegmentLayout } from './computeSegmentLayout'
export type * from './types'
@@ -0,0 +1,36 @@
export type ProgressSegment = {
id: string
label?: string
start: number
end: number
className?: string
style?: Record<string, string>
}
export type ProgressSegmentLayout = ProgressSegment & {
left: number
width: number
center: number
}
export type AnchoredProgressBarProps = {
progress: number
min?: number
max?: number
width?: number
height?: number
dividerWidth?: number
dividerHeight?: number
segments?: ProgressSegment[]
trackClass?: string
fillClass?: string
segmentClass?: string
showDividers?: boolean
markerSrc?: string
markerSize?: number
markerTop?: number
showPercent?: boolean
percentTop?: number
percentClass?: string
percentTextShadow?: string
}
@@ -0,0 +1,125 @@
<script lang="ts" setup>
import { getStaticImage } from '@/utils'
import { ImgButton } from '@/components'
defineOptions({
name: 'CertificateDetailPopup',
})
defineProps<{
date: string // '2026-06-18' 颁发日期
certificateNumber: string // '001' 证书编号
studentNumber: string // '001' 学员数量
}>()
const visible = defineModel<boolean>({ default: false })
const emit = defineEmits<{
close: []
}>()
const handleClose = () => {
visible.value = false
emit('close')
}
</script>
<template>
<Transition
enter-active-class="transition-all duration-500 ease-out"
enter-from-class="opacity-0 scale-95 blur-[6px]"
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-[6px]"
>
<div
v-if="visible"
class="absolute inset-0 z-[120] flex items-center justify-center bg-black/45"
@click.stop
>
<div class="certificate-detail relative text-2xl">
<img
draggable="false"
:src="getStaticImage('components/medal/证书详情')"
class="certificate-detail-img object-contain"
alt=""
/>
<ImgButton
class="absolute right-2 top-0 z-10"
:image="'components/close'"
@click="handleClose"
/>
<!-- 证书编号 -->
<span class="absolute bottom-[154px] left-[390px]">
{{ certificateNumber }}
</span>
<!-- 颁发日期 -->
<span class="absolute bottom-[122px] left-[390px]">{{ date }}</span>
<!-- 多少位学员 -->
<span class="absolute bottom-[468px] left-[948px] text-3xl">
{{ studentNumber }}
</span>
<span class="absolute bottom-[380px] left-[1070px]">
{{ studentNumber }}
</span>
</div>
</div>
</Transition>
</template>
<style scoped>
.certificate-detail {
animation: certificate-detail-pop 650ms cubic-bezier(0.18, 0.9, 0.24, 1.12)
both;
filter: drop-shadow(0 0 38px rgba(133, 236, 255, 0.85));
}
.certificate-detail::before {
content: '';
position: absolute;
inset: 8% 12%;
z-index: -1;
border-radius: 48px;
background: radial-gradient(
circle,
rgba(255, 255, 255, 0.55) 0%,
rgba(117, 235, 255, 0.32) 38%,
rgba(117, 235, 255, 0) 72%
);
animation: certificate-glow 1600ms ease-in-out infinite;
}
.certificate-detail-img {
width: 100%;
height: 100%;
}
@keyframes certificate-detail-pop {
0% {
opacity: 0;
transform: translateY(28px) scale(0.9);
}
58% {
opacity: 1;
transform: translateY(-8px) scale(1.025);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes certificate-glow {
0%,
100% {
opacity: 0.45;
transform: scale(0.98);
}
50% {
opacity: 0.85;
transform: scale(1.04);
}
}
</style>
@@ -0,0 +1,440 @@
<script lang="ts" setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { getBackgroundImage, getStaticImage } from '@/utils'
import {
useBasicElementsStore,
useFilmStore,
useInterviewStore,
useVlogStore,
} from '@/stores'
import {
AnchoredProgressBar,
computeSegmentLayout,
type ProgressSegment,
} from './AnchoredProgressBar'
import { ImgButton } from '@/components'
import CertificateDetailPopup from './CertificateDetailPopup.vue'
import {
studyProgressDisplayConfig,
type StudyProgressModuleKey,
} from './studyProgressConfig'
import {
getBasicElementsProgress,
getOverallStudyProgress,
getProductionModuleProgress,
} from '@/config/studyProgress'
defineOptions({
name: 'StudyProgressPopup',
})
type ProductionModuleKey = Exclude<StudyProgressModuleKey, 'basic'>
type StudyProgressItem = {
key: StudyProgressModuleKey
progress: number
unlocked: boolean
segments: ProgressSegment[]
detailSegments?: ProgressSegment[]
}
const visible = defineModel<boolean>({ default: false })
const basicElements = useBasicElementsStore()
const vlog = useVlogStore()
const interview = useInterviewStore()
const film = useFilmStore()
const ROW_HEIGHT = {
basic: 168,
vlog: 148,
interview: 146,
film: 146,
} satisfies Record<StudyProgressModuleKey, number>
const MAIN_BAR_WIDTH = 1266
const MAIN_BAR_HEIGHT = 34
const MAIN_DIVIDER_HEIGHT = 44
const DETAIL_BAR_WIDTH = 764
const DETAIL_BAR_HEIGHT = 34
const BUBBLE_LEFT = 665
const BADGE_IMAGE = {
basic: '新手导航',
vlog: 'vlog',
interview: '访谈',
film: '电影',
} satisfies Record<StudyProgressModuleKey, string>
const badgeSrc = (item: StudyProgressItem) =>
getStaticImage(
`home/stuy/${BADGE_IMAGE[item.key]}-${item.unlocked ? '亮' : '灰'}`,
)
const toMainPx = (value: number) => Math.round((value / 100) * MAIN_BAR_WIDTH)
const detailCenterPx = (centerPercent: number) =>
Math.round((centerPercent / 100) * DETAIL_BAR_WIDTH)
const labelLeft = (item: StudyProgressItem, segment: ProgressSegment) =>
item.key === 'basic'
? toMainPx((segment.start + segment.end) / 2)
: toMainPx(segment.start)
const labelTop = (key: StudyProgressModuleKey) => (key === 'basic' ? 13 : 3)
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 basicProgress = computed(() =>
getBasicElementsProgress(
basicElements.basicElementsUnlockProgress,
basicElements.isBasicElementsUnlock,
basicElements.learningVideosWatched,
),
)
const vlogProgress = computed(() =>
getProductionModuleProgress(
'vlog',
vlog.isRouteViewed,
vlog.workVideosWatched,
vlog.practiceProgress,
),
)
const interviewProgress = computed(() =>
getProductionModuleProgress(
'interview',
interview.isRouteViewed,
interview.workVideosWatched,
interview.practiceProgress,
),
)
const filmProgress = computed(() => film.progressPercent)
const totalProgress = computed(() =>
getOverallStudyProgress([
basicProgress.value,
vlogProgress.value,
interviewProgress.value,
filmProgress.value,
]),
)
const CERTIFICATE_SHOWN_KEY = 'study-progress-certificate-shown'
const certificateVisible = ref(false)
const certificateTimer = ref<number>()
const hasAllBadges = computed(() =>
[
basicProgress.value,
vlogProgress.value,
interviewProgress.value,
filmProgress.value,
].every((progress) => progress >= 100),
)
const hasCertificate = computed(
() => basicElements.guideShown[CERTIFICATE_SHOWN_KEY] === true,
)
const certificateSrc = computed(() =>
getStaticImage(
`home/stuy/通关证书-${hasCertificate.value ? '已获取' : '暂未获取'}`,
),
)
const buildItem = (
key: StudyProgressModuleKey,
progress: number,
unlocked: boolean,
): StudyProgressItem => {
const config = studyProgressDisplayConfig[key]
return {
key,
progress,
unlocked,
segments: config.segments,
detailSegments: config.detailSegments,
}
}
// 徽章点亮与「获得勋章」的条件保持一致:模块完成度达到 100%
// (basic 因 unlockProgress 恒为 0、isUnlocked 恒为 false,progress=100 等价于所有基础视频看完)
const progressItems = computed<StudyProgressItem[]>(() => [
buildItem('basic', basicProgress.value, basicProgress.value >= 100),
buildItem('vlog', vlogProgress.value, vlogProgress.value >= 100),
buildItem(
'interview',
interviewProgress.value,
interviewProgress.value >= 100,
),
buildItem('film', filmProgress.value, filmProgress.value >= 100),
])
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 clearCertificateTimer = () => {
window.clearTimeout(certificateTimer.value)
certificateTimer.value = undefined
}
const scheduleCertificatePopup = () => {
clearCertificateTimer()
if (!visible.value || !hasAllBadges.value || hasCertificate.value) return
certificateTimer.value = window.setTimeout(() => {
basicElements.ensureCertificate()
certificateVisible.value = true
}, 1000)
}
const closeCertificate = () => {
certificateVisible.value = false
if (!hasCertificate.value) {
basicElements.markGuideShown(CERTIFICATE_SHOWN_KEY)
}
}
watch([visible, hasAllBadges, hasCertificate], scheduleCertificatePopup)
onBeforeUnmount(clearCertificateTimer)
const handleClose = () => {
clearCertificateTimer()
certificateVisible.value = false
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)))
const certificateInfo = computed(() => basicElements.certificate)
</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="absolute 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"
:image="'components/close'"
@click="handleClose"
/>
<img
draggable="false"
:src="certificateSrc"
class="absolute -bottom-12 -right-10 object-contain"
alt=""
/>
<div class="absolute left-0 right-0 top-[150px] flex justify-center">
<div
class="flex items-end pr-10 text-white font-[1000] tracking-widest"
:style="{ textShadow: totalTextShadow }"
>
<span class="text-[56px] leading-none">总进度</span>
<span class="text-[56px] leading-none">{{ totalProgress }}%</span>
</div>
</div>
<div
class="mx-auto mt-[215px] flex w-[1580px] pr-[60px]"
@click.stop
>
<!-- 解锁的徽章 -->
<div class="flex w-[260px] flex-col gap-2 pl-2 pr-3">
<div
v-for="item in progressItems"
:key="item.key"
class="flex items-center justify-center"
:style="{ height: `${ROW_HEIGHT[item.key]}px` }"
>
<img
:src="badgeSrc(item)"
class="w-[144px] object-contain"
alt=""
/>
</div>
</div>
<!-- 进度 -->
<div class="flex-1 flex flex-col gap-2">
<div
v-for="item in progressItems"
:key="item.key"
class="relative w-full pt-4"
: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-[36px]"
:class="[
item.key === 'basic' ? '-translate-x-1/2' : '',
item.key !== 'basic' && seg.id === ZOOM_SEGMENT_ID
? 'group cursor-pointer'
: '',
]"
:style="{
top: `${labelTop(item.key)}px`,
left: `${labelLeft(item, seg)}px`,
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)"
>
<span
:class="[
item.key !== 'basic' && seg.id === ZOOM_SEGMENT_ID
? 'transition-opacity duration-200 group-hover:opacity-75'
: '',
]"
>
{{ seg.label }}
</span>
</div>
<AnchoredProgressBar
class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
:width="MAIN_BAR_WIDTH"
:height="MAIN_BAR_HEIGHT"
:divider-height="MAIN_DIVIDER_HEIGHT"
:progress="item.progress"
:segments="item.segments"
:marker-src="nodeImageSrc"
show-percent
:percent-text-shadow="totalTextShadow"
segment-class="bg-white/10"
/>
<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 z-20 mb-4 w-[860px] -translate-x-1/2"
:style="{ left: `${BUBBLE_LEFT}px` }"
@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"
:style="{ textShadow: labelTextShadow }"
>
<span
class="text-[34px] font-[1000] tracking-wider text-white"
>
制作变焦镜
</span>
<span
class="text-[28px] 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-[28px] font-[1000] tracking-wider text-white"
:style="{
top: '0px',
left: `${detailCenterPx(seg.center)}px`,
textShadow: labelTextShadow,
}"
>
{{ seg.label }}
</div>
<AnchoredProgressBar
class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
:width="DETAIL_BAR_WIDTH"
:height="DETAIL_BAR_HEIGHT"
:min="20"
:max="85"
:progress="item.progress"
:segments="item.detailSegments"
:marker-src="nodeImageSrc"
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"
/>
</div>
</div>
</div>
</Transition>
</div>
</div>
</div>
<CertificateDetailPopup
:date="certificateInfo?.date ?? ''"
:certificate-number="certificateInfo?.number ?? ''"
:student-number="certificateInfo?.studentNumber ?? ''"
v-model="certificateVisible"
@close="closeCertificate"
/>
</div>
</Transition>
</template>
@@ -0,0 +1 @@
export { default as StudyProgressPopup } from './StudyProgressPopup.vue'
@@ -1,4 +1,4 @@
import type { ProgressSegment } from '@/components'
import type { ProgressSegment } from './AnchoredProgressBar'
export type StudyProgressModuleKey = 'basic' | 'vlog' | 'interview' | 'film'
@@ -1,6 +0,0 @@
export {
getBasicElementsProgress,
getOverallStudyProgress,
getProductionModuleProgress,
type ProductionModuleKey,
} from '@/config/studyProgress'
+41 -9
View File
@@ -6,7 +6,12 @@ import {
IMAGE_PATHS,
} from '@/utils'
import { ImgButton } from '@/components/Button'
import { CourseGuidePopup, CourseFrameworkPopup, GuideSteps, 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'
@@ -19,7 +24,7 @@ const CourseFrameGuide = () => (
src={getStaticImage(IMAGE_PATHS.BUTTONS.COURSE_FRAME)}
alt=""
/>
查看课程结构,快速了解每个模块的学习内容。
查看课程整体结构,快速了解每个模块的学习内容。
</div>
)
@@ -30,7 +35,7 @@ const OperateGuide = () => (
src={getStaticImage(IMAGE_PATHS.BUTTONS.OPERATE_GUIDE)}
alt=""
/>
包含课程介绍和操作指南,点击即可查看!
包含课程介绍和操作指引,点击即可查看!
</div>
)
@@ -90,12 +95,20 @@ export default defineComponent({
const { scale, offsetX, offsetY } = useScale()
// 首次进入的视频播放序列
const introVideos = computed(() => [getPublicPath('video/start.mp4')])
const introVideos = computed(() => [
getPublicPath('video/start.mp4'),
getPublicPath('video/guide.mp4'),
])
const introVideoIndex = ref(0)
const showIntroVideo = ref(isFirstVisit)
const currentIntroSrc = computed(
() => introVideos.value[introVideoIndex.value] ?? '',
)
const currentIntroPoster = computed(() =>
introVideoIndex.value === 0
? (getStaticImage('cover/start.jpg') as string)
: '',
)
if (isFirstVisit) {
basicElementsStore.markGuideShown(guideKey)
@@ -114,6 +127,10 @@ export default defineComponent({
}
}
const handleSkipIntro = () => {
showIntroVideo.value = false
}
// Loading 加载状态
const showLoading = ref(isFirstVisit)
const loadingProgress = ref(0)
@@ -187,7 +204,7 @@ export default defineComponent({
style={{ backgroundImage: getBackgroundImage(IMAGE_PATHS.HOME_BG) }}
/>
<div class="absolute right-[420px] bottom-[200px] start-filming-wrapper">
<div class="absolute right-[420px] bottom-[200px] start-filming-wrapper animate-start-filming-breath hover:animate-none hover:scale-110 active:scale-95 transition-transform duration-300 ease-out">
<ImgButton
image="home/action-btn"
path={{ name: 'Action' }}
@@ -209,12 +226,14 @@ export default defineComponent({
{showIntroVideo.value && (
<div class="absolute inset-0 z-[10000] bg-black flex items-center justify-center">
<Loading
visible={showLoading.value}
progress={loadingProgress.value}
/>
<Video
class={
showLoading.value
? 'opacity-0 pointer-events-none'
: 'opacity-100 transition-opacity duration-300'
}
src={currentIntroSrc.value}
poster={currentIntroPoster.value}
autoplay={true}
muted={false}
controls={true}
@@ -228,6 +247,19 @@ export default defineComponent({
onPlayfailed={finishLoading}
onEnded={handleIntroVideoEnded}
/>
<Loading
visible={showLoading.value}
progress={loadingProgress.value}
/>
{introVideoIndex.value > 0 && !showLoading.value && (
<img
class="absolute top-10 right-10 z-[10001] w-32 cursor-pointer transition-[transform,filter] duration-300 hover:scale-105 hover:brightness-110"
draggable={false}
src={getStaticImage(IMAGE_PATHS.BUTTONS.ESCAPE)}
alt="跳过"
onClick={handleSkipIntro}
/>
)}
</div>
)}
@@ -171,13 +171,13 @@ onBeforeRouteLeave((to) => {
@click="handleClose"
/>
<button
class="absolute right-4 top-1/2 z-10 -translate-y-1/2 flex h-12 w-12 items-center justify-center rounded-full bg-blue-500/80 pointer-events-auto text-white transition-all duration-200 hover:bg-blue-500"
class="absolute right-4 top-1/2 z-10 -translate-y-1/2 flex h-16 w-16 items-center justify-center rounded-full bg-blue-500/80 pointer-events-auto text-white transition-all duration-200 hover:bg-blue-500"
title="步骤导航"
@click="showStepNavigation = true"
>
<svg
width="20"
height="20"
width="35"
height="35"
viewBox="0 0 20 20"
fill="none"
>
@@ -4,13 +4,19 @@ import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { Video } from '@/components'
import { IMAGE_PATHS, getPublicPath, getVideoPoster } from '@/utils'
import { useInterviewStore } from '@/stores'
const router = useRouter()
const interviewStore = useInterviewStore()
const playing = ref(false)
const handleBack = () => {
router.back()
}
const handleEnded = () => {
interviewStore.markViewed('InterviewSoundEffect')
}
</script>
<template>
@@ -28,6 +34,7 @@ const handleBack = () => {
:volume="1"
:controls="true"
:hideNativeControls="true"
@ended="handleEnded"
/>
<ImgButton
class="absolute right-0 top-0 z-20"
+64 -1
View File
@@ -3,16 +3,28 @@ defineOptions({ name: 'Interview' })
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button'
import { computed, ref, watch, type Ref } from 'vue'
import { computed, nextTick, onMounted, ref, watch, type Ref } from 'vue'
import { ProgressBar, Overlay } from '@/components'
import { GuideSteps, GuideStepsItem, type GuideStep } from '@/components/Guide'
import { useRoute, useRouter } from 'vue-router'
import { useInterviewStore } from '@/stores'
import {
ModuleMedalPopup,
useModuleMedalPopup,
} from '@/views/home/components/ModuleMedalPopup'
import { StudyProgressPopup } from '@/views/home/components/StudyProgressPopup'
import InterviewMenu from './components/menu.vue'
const route = useRoute()
const router = useRouter()
const interview = useInterviewStore()
const {
activeMedalKey,
visible: medalPopupVisible,
showMedalIfNeeded,
closeMedal,
} = useModuleMedalPopup()
const pendingInterviewMedal = ref(false)
const showReturnGuide = ref(
route.query.guide === 'return' && interview.isGuideInterviewReturn,
@@ -114,6 +126,31 @@ const visible = computed({
const currentStep = computed(() => interview.InterviewUnlockProgress)
const progress = computed(() => interview.progressPercent)
const isInterviewCompleted = computed(() => progress.value >= 100)
const showPendingInterviewMedal = () => {
if (!pendingInterviewMedal.value) return
if (
showReturnGuide.value ||
stageGuideStep.value ||
showPracticeGuide.value
) {
return
}
pendingInterviewMedal.value = false
showMedalIfNeeded('interview')
}
const handleCloseMedalPopup = () => {
closeMedal()
}
const studyProgressVisible = ref(false)
const handleViewProgressFromMedal = () => {
closeMedal()
studyProgressVisible.value = true
}
// 关闭学习进度弹窗后默认返回首页
watch(studyProgressVisible, (value) => {
if (!value) router.push({ name: 'Action' })
})
const handleComplete = () => {
// console.log('完成前置条件')
@@ -145,6 +182,23 @@ watch(
},
{ immediate: true },
)
watch(isInterviewCompleted, (completed, previousCompleted) => {
if (!completed || previousCompleted !== false) return
pendingInterviewMedal.value = true
nextTick(showPendingInterviewMedal)
})
watch([showReturnGuide, stageGuideStep, showPracticeGuide], () => {
nextTick(showPendingInterviewMedal)
})
onMounted(() => {
if (isInterviewCompleted.value) {
pendingInterviewMedal.value = true
}
showPendingInterviewMedal()
})
</script>
<template>
@@ -271,5 +325,14 @@ watch(
@complete="handleStageGuideComplete"
@skip="handleStageGuideComplete"
/>
<ModuleMedalPopup
:visible="medalPopupVisible"
:module-key="activeMedalKey"
@close="handleCloseMedalPopup"
@view-progress="handleViewProgressFromMedal"
/>
<StudyProgressPopup v-model="studyProgressVisible" />
</div>
</template>
@@ -207,13 +207,13 @@ onBeforeRouteLeave((to) => {
@click="back"
/>
<button
class="absolute right-4 top-1/2 z-20 -translate-y-1/2 w-12 h-12 rounded-full bg-blue-500/80 text-white transition-all duration-200 hover:bg-blue-500 pointer-events-auto flex items-center justify-center"
class="absolute right-4 top-1/2 z-20 -translate-y-1/2 w-16 h-16 rounded-full bg-blue-500/80 text-white transition-all duration-200 hover:bg-blue-500 pointer-events-auto flex items-center justify-center"
title="资源列表"
@click="showStepNavigation = true"
>
<svg
width="20"
height="20"
width="35"
height="35"
viewBox="0 0 20 20"
fill="none"
>
@@ -12,7 +12,7 @@ export function useViewfinderA() {
// 缩放级别 (保留用于点击按钮)
const zoomLevels = [3, 2, 1]
const currentZoom = ref(1)
const currentZoom = ref(2)
const isCapturing = ref(false) // 拍照闪光效果
// 拖拽相关状态
@@ -44,8 +44,14 @@ export function useViewfinderA() {
const maxOffsetY = (scaledHeight - containerHeight) / 2
// 限制偏移量
currentOffsetX.value = Math.max(-maxOffsetX, Math.min(maxOffsetX, currentOffsetX.value))
currentOffsetY.value = Math.max(-maxOffsetY, Math.min(maxOffsetY, currentOffsetY.value))
currentOffsetX.value = Math.max(
-maxOffsetX,
Math.min(maxOffsetX, currentOffsetX.value),
)
currentOffsetY.value = Math.max(
-maxOffsetY,
Math.min(maxOffsetY, currentOffsetY.value),
)
}
// 设置缩放级别
@@ -144,6 +150,6 @@ export function useViewfinderA() {
onDrag,
endDrag,
handleWheel,
getTransform
getTransform,
}
}
+3 -1
View File
@@ -116,6 +116,7 @@ interface WorkItem {
title: string
stars: number
path: string
watchKey: string
description: string
}
@@ -126,6 +127,7 @@ const worksList = ref<WorkItem[]>([
title: '匠心育人润物无声',
stars: 4,
path: 'video/interview/practice/fangtan_cace1_batch.mp4',
watchKey: 'interview-practice-case-1',
description: '26081 沈佳杰 吴思颖 韩心妍 王梓卉 邱政豪\n25084 张鸿斌',
},
// {
@@ -388,7 +390,7 @@ onUnmounted(() => {
<ImgButton
:class="work.id === worksList[0]?.id ? 'view-btn' : ''"
image="vlog/practice/查看"
:path="`/interview/practice/video/${encodeURIComponent(work.path)}`"
:path="`/interview/practice/video/${encodeURIComponent(work.path)}?watchKey=${work.watchKey}`"
/>
</div>
</div>
+12 -3
View File
@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { Video } from '@/components'
import { ImgButton } from '@/components/Button'
import PracticeRatingModal, {
@@ -11,6 +11,7 @@ import { computed, ref } from 'vue'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
const router = useRouter()
const route = useRoute()
const store = useInterviewStore()
const guideKey = 'interview-practice-video-rating'
@@ -88,6 +89,13 @@ const handleSaveEvaluation = (items: PracticeRatingItem[]): void => {
store.setPracticeWorkRating(workKey.value, toScores(items))
}
const handleVideoEnded = (): void => {
const watchKey = route.query.watchKey
if (typeof watchKey === 'string' && watchKey) {
store.markPracticeVideoWatched(watchKey)
}
}
/** 保存弹窗 */
const showModal = ref<boolean>(false)
@@ -105,7 +113,7 @@ const handleBack = (): void => {
@click="handleBack"
/>
<ImgButton
class="absolute right-0 top-1/2 -translate-y-1/2 z-50"
class="btn-1 absolute right-0 top-1/2 -translate-y-1/2 z-50"
image="vlog/practice/评分"
@click="showModal = true"
/>
@@ -116,6 +124,7 @@ const handleBack = (): void => {
:muted="false"
:controls="true"
:loop="false"
@ended="handleVideoEnded"
/>
<PracticeRatingModal
@@ -130,7 +139,7 @@ const handleBack = (): void => {
<GuideSteps
:visible="showGuide"
highlight-selector=".btn-1"
position-offset-y="60%"
position-offset-y="85%"
@complete="handleRatingGuideComplete"
>
<GuideStepsItem audio-src="/audio/py_6_008.MP3">
+56 -5
View File
@@ -1,7 +1,8 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue'
import { computed, defineComponent, h, onMounted, ref } from 'vue'
import { onBeforeRouteLeave, useRouter, useRoute } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue'
import DownloadButton from '@/components/DownloadButton.vue'
import { GuideSteps, type GuideStep } from '@/components/Guide'
import StepNavigation from '@/components/Guide/StepNavigation.vue'
import UnfinishedLearningPrompt from '@/components/UnfinishedLearningPrompt'
@@ -106,6 +107,46 @@ const getQuestionsForVideo = (index: number): Question[] => {
return []
}
const StepNavigationGuideContent = defineComponent({
name: 'StepNavigationGuideContent',
setup() {
return () =>
h('div', { class: 'flex flex-wrap items-center justify-start' }, [
h('div', { class: 'text-yellow-400' }, [
'点击',
h(
'button',
{
class:
'w-10 h-10 bg-blue-500/80 rounded-full inline-flex items-center justify-center text-white transition-all duration-200',
title: '步骤导航',
},
[
h(
'svg',
{
width: '20',
height: '20',
viewBox: '0 0 20 20',
fill: 'none',
},
[
h('path', {
d: 'M3 4H17M3 8H17M3 12H17M3 16H17',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
}),
],
),
],
),
'可以切换不同的学习内容!',
]),
])
},
})
//引导步骤
const steps = ref<GuideStep[]>([
{
@@ -133,6 +174,12 @@ const introGuideSteps = computed<GuideStep[]>(() => {
audioSrc: getPublicPath('audio/py_3_005.MP3'),
content: '那么,让我们一起来拆解访谈作品的核心技能吧!',
},
{
id: 'profession-intro3',
audioSrc: getPublicPath('audio/py_1_008_2.MP3'),
highlightSelector: '.step-navigation-btn',
content: StepNavigationGuideContent,
},
]
}
@@ -377,13 +424,13 @@ onBeforeRouteLeave((to) => {
<!-- 步骤导航按钮 -->
<button
v-if="key === 'profession'"
class="absolute right-4 top-1/2 z-10 -translate-y-1/2 w-12 h-12 bg-blue-500/80 hover:bg-blue-500 rounded-full flex items-center justify-center text-white transition-all duration-200"
class="step-navigation-btn absolute right-4 top-1/2 z-10 -translate-y-1/2 w-16 h-16 bg-blue-500/80 hover:bg-blue-500 rounded-full flex items-center justify-center text-white transition-all duration-200"
@click="showStepNavigation = true"
title="步骤导航"
>
<svg
width="20"
height="20"
width="35"
height="35"
viewBox="0 0 20 20"
fill="none"
>
@@ -434,13 +481,17 @@ onBeforeRouteLeave((to) => {
<div
v-else-if="key === 'idea'"
:src="image"
class="cursor-pointer"
class="relative cursor-pointer"
@click="handleIdeaImageClick"
>
<img
:src="ideaImage"
alt=""
/>
<DownloadButton
:image-url="ideaImage"
file-name="访谈思路提点.png"
/>
</div>
</div>
@@ -51,6 +51,7 @@ const items = computed(() => [
activeImage: 'work/选中_带三角',
inactiveImage: 'work/未选中_带三角',
statusImage: getStatusImage('case'),
completed: interview.workVideosWatched.case,
},
{
key: 'profession',
@@ -58,6 +59,7 @@ const items = computed(() => [
activeImage: 'work/选中_带三角',
inactiveImage: 'work/未选中_带三角',
statusImage: getStatusImage('profession'),
completed: interview.workVideosWatched.profession,
},
{
key: 'idea',
@@ -65,6 +67,7 @@ const items = computed(() => [
activeImage: 'work/选中_带三角',
inactiveImage: 'work/未选中_带三角',
statusImage: getStatusImage('idea'),
completed: interview.workVideosWatched.idea,
},
])
+61 -49
View File
@@ -98,7 +98,7 @@ const guideSteps = ref([
id: 'step-6',
title: '',
content: '点击资源即可回顾微课视频或跳转到图文、交互等相关页面。',
highlightSelector: '.skill-tree-container',
highlightSelector: '.scrollbar-thin',
audioSrc: getPublicPath('audio/py_5_006.MP3'),
},
{
@@ -106,16 +106,16 @@ const guideSteps = ref([
title: '',
content:
'在搜索栏输入关键字,可快速搜索资源,支持按标题、模块、标签等字段搜索。',
highlightSelector: '.scrollbar-thin',
highlightSelector: '.search-box-container',
audioSrc: getPublicPath('audio/py_5_007.MP3'),
},
{
id: 'step-8',
title: '',
content: '点击【删除】按钮或【清除筛选】按钮可一键清空筛选内容。',
highlightSelector: '.search-box-container',
audioSrc: getPublicPath('audio/py_5_008.MP3'),
},
// {
// id: 'step-8',
// title: '',
// content: '点击【删除】按钮或【清除筛选】按钮可一键清空筛选内容。',
// highlightSelector: '.search-box-container',
// audioSrc: getPublicPath('audio/py_5_008.MP3'),
// },
])
/**
@@ -235,7 +235,11 @@ const currentFilterText = computed(() => {
return searchTags.value.map((tag) => tag.name).join(' / ')
}
return search.value.trim()
if (search.value.trim()) {
return search.value.trim()
}
return '技能树总览'
})
const currentSelectedTagName = computed(() => {
@@ -332,12 +336,14 @@ const resourceLearnedMap = computed(() =>
viewed: vlogStore.viewed,
professionVideosWatched: vlogStore.professionVideosWatched,
workVideosWatched: vlogStore.workVideosWatched,
practiceVideosWatched: vlogStore.practiceVideosWatched,
isRouteViewed: vlogStore.isRouteViewed,
},
interview: {
viewed: interviewStore.viewed,
professionVideosWatched: interviewStore.professionVideosWatched,
workVideosWatched: interviewStore.workVideosWatched,
practiceVideosWatched: interviewStore.practiceVideosWatched,
isRouteViewed: interviewStore.isRouteViewed,
},
film: {
@@ -345,6 +351,7 @@ const resourceLearnedMap = computed(() =>
editingVideosWatched: filmStore.editingVideosWatched,
professionVideosWatched: filmStore.professionVideosWatched,
workVideosWatched: filmStore.workVideosWatched,
practiceVideosWatched: filmStore.practiceVideosWatched,
isRouteViewed: filmStore.isRouteViewed,
},
}),
@@ -496,46 +503,51 @@ const handleGuideSkip = () => {
@click="handleClose"
/>
<!-- 搜索框 -->
<div
class="search-box-container absolute left-10 top-10 w-[547px] h-[84px] bg-cover bg-center pl-[130px] pr-[30px] pt-[20px] pb-[21px] flex items-center gap-2 overflow-hidden"
:style="{
backgroundImage: getBackgroundImage('skill/搜索框-已搜索.png'),
backgroundSize: 'cover',
}"
>
<!-- 搜索标签显示 -->
<div class="flex-shrink-0 overflow-x-auto scrollbar-hide">
<SearchTags
:tags="searchTags"
@remove="handleRemoveTag"
<!-- 检索 -->
<div class="search-box-container absolute left-10 top-10 pb-8">
<!-- 搜索框 -->
<div
class="w-[547px] h-[84px] bg-cover bg-center pl-[130px] pr-[30px] pt-[20px] pb-[21px] flex items-center gap-2 overflow-hidden"
:style="{
backgroundImage: getBackgroundImage('skill/搜索框-已搜索.png'),
backgroundSize: 'cover',
}"
>
<!-- 搜索标签显示 -->
<div class="flex-shrink-0 overflow-x-auto scrollbar-hide">
<SearchTags
:tags="searchTags"
@remove="handleRemoveTag"
/>
</div>
<!-- 搜索文本输入(可选,用于额外搜索) -->
<input
v-model="search"
type="text"
name="skill"
:placeholder="searchTags.length === 0 ? '输入关键字检索' : ''"
maxlength="14"
class="flex-1 min-w-0 h-full bg-transparent border-none placeholder:text-white placeholder:text-2xl outline-none text-white text-2xl"
/>
</div>
<!-- 搜索文本输入(可选,用于额外搜索) -->
<input
v-model="search"
type="text"
name="skill"
:placeholder="searchTags.length === 0 ? '请搜索检索' : ''"
maxlength="14"
class="flex-1 min-w-0 h-full bg-transparent border-none placeholder:text-white placeholder:text-2xl outline-none text-white text-2xl"
/>
</div>
<div
v-if="hasActiveFilter"
class="absolute left-10 top-[108px] z-20 flex items-center gap-4 rounded-full border-2 border-white/80 bg-[#1b8fd8]/85 px-6 py-3 text-white shadow-lg backdrop-blur-sm"
>
<span class="text-2xl font-bold">当前查看:{{ currentFilterText }}</span>
<span class="text-xl opacity-90">
共 {{ filteredResources.length }} 个资源
</span>
<button
class="rounded-full bg-white/25 px-4 py-1 text-xl font-bold transition-all hover:bg-white/40"
@click="clearAllFilters"
<div
class="absolute left-0 top-[70px] z-20 flex items-center gap-4 whitespace-nowrap rounded-full border-2 border-white/80 bg-[#1b8fd8]/85 px-6 py-3 text-white shadow-lg backdrop-blur-sm"
>
清除筛选
</button>
<span class="text-2xl font-bold">
当前查看:{{ currentFilterText }}
</span>
<span class="text-xl text-white/90">
共 {{ filteredResources.length }} 个资源
</span>
<button
v-if="hasActiveFilter"
class="rounded-full bg-white/25 px-4 py-1 text-xl font-bold transition-all hover:bg-white/40"
@click="clearAllFilters"
>
清除筛选
</button>
</div>
</div>
<!-- 模块列表 -->
@@ -597,12 +609,12 @@ const handleGuideSkip = () => {
<button
v-for="subTag in availableSubTags"
:key="subTag.id"
class="flex h-[116px] w-[104px] shrink-0 flex-col items-center justify-center rounded-2xl border-2 transition-all hover:scale-105"
class="flex shrink-0 flex-col items-center justify-center rounded-2xl border-2 transition-all hover:scale-105"
:class="
selectedSubTagId === subTag.id ||
selectedSubTagId === subTag.name
? 'border-white bg-white/35 shadow-lg'
: 'border-white/45 bg-[#1597d7]/45'
: 'border-white/45 bg-[#1597d7]/15'
"
@click="
handleSubTagClick(
@@ -613,7 +625,7 @@ const handleGuideSkip = () => {
"
>
<img
class="object-contain"
:width="104"
:src="getStaticImage(getSubTagIcon(subTag.name))"
:alt="subTag.name"
/>
+13 -15
View File
@@ -618,10 +618,9 @@ const vlogResources: ResourceItem[] = [
tagName: '后期制作',
subTagId: 'subtag-2-3-3',
subTagName: '调色',
type: 'video',
videoUrl: '/video/vlog/color/Vlogmk_tsybz_batch.mp4',
storeKey: 'VlogColor',
completionMode: 'quiz-complete',
type: 'page',
routeName: 'VlogColor',
completionMode: 'aggregate',
},
{
id: 'res-46',
@@ -644,7 +643,7 @@ const vlogResources: ResourceItem[] = [
tagName: '思路提点与案例赏析',
type: 'video',
videoUrl: '/video/vlog/practice/vlog_case1_batch.mp4',
storeKey: 'case',
storeKey: 'vlog-practice-case-1',
completionMode: 'video-ended',
},
{
@@ -656,7 +655,7 @@ const vlogResources: ResourceItem[] = [
tagName: '思路提点与案例赏析',
type: 'video',
videoUrl: '/video/vlog/practice/vlog_case2_batch.mp4',
storeKey: 'profession',
storeKey: 'vlog-practice-case-2',
completionMode: 'video-ended',
},
]
@@ -782,7 +781,7 @@ const interviewResources: ResourceItem[] = [
type: 'page',
routeName: 'InterviewScriptWriting',
step: 1,
completionMode: 'video-ended',
completionMode: 'stay-3s',
},
{
id: 'res-58',
@@ -960,10 +959,9 @@ const interviewResources: ResourceItem[] = [
tagName: '后期制作',
subTagId: 'subtag-3-3-5',
subTagName: '音效',
type: 'video',
videoUrl: '/video/interview/editing/ftmk_yx_batch.mp4',
storeKey: 'InterviewSoundEffect',
completionMode: 'stay-3s',
type: 'page',
routeName: 'InterviewSoundEffect',
completionMode: 'video-ended',
},
{
id: 'res-71',
@@ -986,7 +984,7 @@ const interviewResources: ResourceItem[] = [
tagName: '思路提点与案例赏析',
type: 'video',
videoUrl: '/video/interview/practice/fangtan_cace1_batch.mp4',
storeKey: 'case',
storeKey: 'interview-practice-case-1',
completionMode: 'video-ended',
},
]
@@ -1157,7 +1155,7 @@ const filmResources: ResourceItem[] = [
type: 'page',
routeName: 'FilmScriptWriting',
step: 1,
completionMode: 'video-ended',
completionMode: 'stay-3s',
},
// {
// id: 'res-85',
@@ -1238,7 +1236,7 @@ const filmResources: ResourceItem[] = [
subTagName: '拍摄技巧',
type: 'page',
routeName: 'FilmOnsiteTimeLapse',
completionMode: 'video-ended',
completionMode: 'quiz-complete',
},
// 拍摄执行与后期制作视频
@@ -1440,7 +1438,7 @@ const filmResources: ResourceItem[] = [
tagName: '思路提点与案例赏析',
type: 'video',
videoUrl: '/video/film/practice/film_anli1.mp4',
storeKey: 'case',
storeKey: 'film-practice-case-1',
completionMode: 'video-ended',
},
]
+6
View File
@@ -8,12 +8,14 @@ export type SkillStoreState = {
viewed: Record<string, boolean>
professionVideosWatched: Record<string, boolean>
workVideosWatched: Record<string, boolean>
practiceVideosWatched: Record<string, boolean>
isRouteViewed: (name: string) => boolean
}
interview: {
viewed: Record<string, boolean>
professionVideosWatched: Record<string, boolean>
workVideosWatched: Record<string, boolean>
practiceVideosWatched: Record<string, boolean>
isRouteViewed: (name: string) => boolean
}
film: {
@@ -21,6 +23,7 @@ export type SkillStoreState = {
editingVideosWatched: Record<string, boolean>
professionVideosWatched: Record<string, boolean>
workVideosWatched: Record<string, boolean>
practiceVideosWatched: Record<string, boolean>
isRouteViewed: (name: string) => boolean
}
}
@@ -40,12 +43,14 @@ const isModuleVideoUnlocked = (
return (
stores.vlog.professionVideosWatched[resource.storeKey] === true ||
stores.vlog.workVideosWatched[resource.storeKey] === true ||
stores.vlog.practiceVideosWatched[resource.storeKey] === true ||
stores.vlog.isRouteViewed(resource.storeKey)
)
case 'module-3':
return (
stores.interview.professionVideosWatched[resource.storeKey] === true ||
stores.interview.workVideosWatched[resource.storeKey] === true ||
stores.interview.practiceVideosWatched[resource.storeKey] === true ||
stores.interview.isRouteViewed(resource.storeKey)
)
case 'module-4':
@@ -53,6 +58,7 @@ const isModuleVideoUnlocked = (
stores.film.editingVideosWatched[resource.storeKey] === true ||
stores.film.professionVideosWatched[resource.storeKey] === true ||
stores.film.workVideosWatched[resource.storeKey] === true ||
stores.film.practiceVideosWatched[resource.storeKey] === true ||
stores.film.isRouteViewed(resource.storeKey)
)
default:
+3 -3
View File
@@ -189,13 +189,13 @@ onBeforeRouteLeave((to) => {
@click="handleClose"
/>
<button
class="absolute right-4 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-blue-500/80 text-white transition-all duration-200 hover:bg-blue-500 pointer-events-auto"
class="absolute right-4 top-1/2 z-10 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full bg-blue-500/80 text-white transition-all duration-200 hover:bg-blue-500 pointer-events-auto"
title="步骤导航"
@click="showStepNavigation = true"
>
<svg
width="20"
height="20"
width="35"
height="35"
viewBox="0 0 20 20"
fill="none"
>
@@ -10,7 +10,7 @@ const router = useRouter()
const vlogStore = useVlogStore()
const guideKey = 'vlog-export-publish-intro'
const videoUrl = getPublicPath('video/vlog/publish/vlog_wdzyzsj.mp4')
const videoUrl = getPublicPath('video/vlog/publish/Vlog_wdzyzsj.mp4')
const videoPlaying = ref(false)
const showGuide = ref(!vlogStore.guideShown[guideKey])
+64 -1
View File
@@ -2,16 +2,28 @@
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button'
import { computed, ref, watch, type Ref } from 'vue'
import { computed, nextTick, onMounted, ref, watch, type Ref } from 'vue'
import { ProgressBar, Overlay } from '@/components'
import { GuideSteps, GuideStepsItem, type GuideStep } from '@/components/Guide'
import { useRoute, useRouter } from 'vue-router'
import { useVlogStore } from '@/stores'
import {
ModuleMedalPopup,
useModuleMedalPopup,
} from '@/views/home/components/ModuleMedalPopup'
import { StudyProgressPopup } from '@/views/home/components/StudyProgressPopup'
import VlogMenu from './components/menu.vue'
const route = useRoute()
const router = useRouter()
const vlog = useVlogStore()
const {
activeMedalKey,
visible: medalPopupVisible,
showMedalIfNeeded,
closeMedal,
} = useModuleMedalPopup()
const pendingVlogMedal = ref(false)
const visible = computed({
get: () => vlog.vlogMenuVisible,
@@ -30,6 +42,31 @@ const hasShownPracticeGuide = computed(
const showPracticeGuide = computed(
() => currentStep.value === 4 && !hasShownPracticeGuide.value,
)
const isVlogCompleted = computed(() => progress.value >= 100)
const showPendingVlogMedal = () => {
if (!pendingVlogMedal.value) return
if (
showReturnGuide.value ||
stageGuideStep.value ||
showPracticeGuide.value
) {
return
}
pendingVlogMedal.value = false
showMedalIfNeeded('vlog')
}
const handleCloseMedalPopup = () => {
closeMedal()
}
const studyProgressVisible = ref(false)
const handleViewProgressFromMedal = () => {
closeMedal()
studyProgressVisible.value = true
}
// 关闭学习进度弹窗后默认返回首页
watch(studyProgressVisible, (value) => {
if (!value) router.push({ name: 'Action' })
})
const showReturnGuide = ref(
route.query.guide === 'return' && vlog.isGuideVlogReturn,
@@ -176,6 +213,23 @@ watch(
},
{ immediate: true },
)
watch(isVlogCompleted, (completed, previousCompleted) => {
if (!completed || previousCompleted !== false) return
pendingVlogMedal.value = true
nextTick(showPendingVlogMedal)
})
watch([showReturnGuide, stageGuideStep, showPracticeGuide], () => {
nextTick(showPendingVlogMedal)
})
onMounted(() => {
if (isVlogCompleted.value) {
pendingVlogMedal.value = true
}
showPendingVlogMedal()
})
</script>
<template>
@@ -305,5 +359,14 @@ watch(
@complete="handleStageGuideComplete"
@skip="handleStageGuideComplete"
/>
<ModuleMedalPopup
:visible="medalPopupVisible"
:module-key="activeMedalKey"
@close="handleCloseMedalPopup"
@view-progress="handleViewProgressFromMedal"
/>
<StudyProgressPopup v-model="studyProgressVisible" />
</div>
</template>
@@ -221,13 +221,13 @@ onBeforeRouteLeave((to) => {
@click="handleClose"
/>
<button
class="absolute right-4 top-1/2 z-20 -translate-y-1/2 w-12 h-12 rounded-full bg-blue-500/80 text-white transition-all duration-200 hover:bg-blue-500 pointer-events-auto flex items-center justify-center"
class="absolute right-4 top-1/2 z-20 -translate-y-1/2 w-16 h-16 rounded-full bg-blue-500/80 text-white transition-all duration-200 hover:bg-blue-500 pointer-events-auto flex items-center justify-center"
title="资源列表"
@click="showStepNavigation = true"
>
<svg
width="20"
height="20"
width="35"
height="35"
viewBox="0 0 20 20"
fill="none"
>
@@ -12,7 +12,7 @@ export function useViewfinderA() {
// 缩放级别 (保留用于点击按钮)
const zoomLevels = [3, 2, 1]
const currentZoom = ref(1)
const currentZoom = ref(2)
const isCapturing = ref(false) // 拍照闪光效果
// 拖拽相关状态
@@ -44,8 +44,14 @@ export function useViewfinderA() {
const maxOffsetY = (scaledHeight - containerHeight) / 2
// 限制偏移量
currentOffsetX.value = Math.max(-maxOffsetX, Math.min(maxOffsetX, currentOffsetX.value))
currentOffsetY.value = Math.max(-maxOffsetY, Math.min(maxOffsetY, currentOffsetY.value))
currentOffsetX.value = Math.max(
-maxOffsetX,
Math.min(maxOffsetX, currentOffsetX.value),
)
currentOffsetY.value = Math.max(
-maxOffsetY,
Math.min(maxOffsetY, currentOffsetY.value),
)
}
// 设置缩放级别
@@ -144,6 +150,6 @@ export function useViewfinderA() {
onDrag,
endDrag,
handleWheel,
getTransform
getTransform,
}
}
+4 -1
View File
@@ -116,6 +116,7 @@ interface WorkItem {
title: string
stars: number
path: string
watchKey: string
description: string
}
@@ -126,6 +127,7 @@ const worksList = ref<WorkItem[]>([
title: '熙熙攘攘我们的劳动',
stars: 4,
path: '/video/vlog/practice/vlog_case1_batch.mp4',
watchKey: 'vlog-practice-case-1',
description: '26084 曹毅诚 杨子悦 张馨月 刘政亚 吴若涵 叶雨鑫',
},
{
@@ -134,6 +136,7 @@ const worksList = ref<WorkItem[]>([
title: '匠心精神劳动承匠心',
stars: 5,
path: '/video/vlog/practice/vlog_case2_batch.mp4',
watchKey: 'vlog-practice-case-2',
description: '26081 刘欢欢 锁怡雯 苏佳琪 陶恩泽 王子严 史贺元',
},
])
@@ -388,7 +391,7 @@ onUnmounted(() => {
<ImgButton
:class="work.id === worksList[0]?.id ? 'view-btn' : ''"
image="vlog/practice/查看"
:path="`/vlog/practice/video/${encodeURIComponent(work.path)}`"
:path="`/vlog/practice/video/${encodeURIComponent(work.path)}?watchKey=${work.watchKey}`"
/>
</div>
</div>
+12 -3
View File
@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { Video } from '@/components'
import { ImgButton } from '@/components/Button'
import PracticeRatingModal, {
@@ -11,6 +11,7 @@ import { computed, ref } from 'vue'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
const router = useRouter()
const route = useRoute()
const store = useVlogStore()
const guideKey = 'vlog-practice-video-rating'
@@ -89,6 +90,13 @@ const handleSaveEvaluation = (items: PracticeRatingItem[]): void => {
store.setPracticeWorkRating(workKey.value, toScores(items))
}
const handleVideoEnded = (): void => {
const watchKey = route.query.watchKey
if (typeof watchKey === 'string' && watchKey) {
store.markPracticeVideoWatched(watchKey)
}
}
/** 保存弹窗 */
const showModal = ref<boolean>(false)
@@ -106,7 +114,7 @@ const handleBack = (): void => {
@click="handleBack"
/>
<ImgButton
class="absolute right-0 top-1/2 -translate-y-1/2 z-50"
class="btn-1 absolute right-0 top-1/2 -translate-y-1/2 z-50"
image="vlog/practice/评分"
@click="showModal = true"
/>
@@ -117,6 +125,7 @@ const handleBack = (): void => {
:muted="false"
:controls="true"
:loop="false"
@ended="handleVideoEnded"
/>
<PracticeRatingModal
@@ -131,7 +140,7 @@ const handleBack = (): void => {
<GuideSteps
:visible="showGuide"
highlight-selector=".btn-1"
position-offset-y="60%"
position-offset-y="85%"
@complete="handleRatingGuideComplete"
>
<GuideStepsItem audio-src="/audio/py_6_008.MP3">
+89 -25
View File
@@ -1,14 +1,14 @@
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { computed, defineComponent, h, onMounted, ref } from 'vue'
import { onBeforeRouteLeave, useRouter, useRoute } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue'
import { GuideSteps } from '@/components/Guide'
import DownloadButton from '@/components/DownloadButton.vue'
import { GuideSteps, type GuideStep } from '@/components/Guide'
import StepNavigation from '@/components/Guide/StepNavigation.vue'
import UnfinishedLearningPrompt from '@/components/UnfinishedLearningPrompt'
import { Video, ImagePreview } from '@/components'
import { useScale } from '@/composables/useScale'
import {
getBackgroundImage,
getPublicPath,
getStaticImage,
IMAGE_PATHS,
@@ -26,7 +26,69 @@ const { scale, offsetX, offsetY } = useScale()
const playing = ref(false)
const status = ref(false)
const steps = ref<{ id: string; content: string; audioSrc?: string }[]>([])
const steps = ref<GuideStep[]>([])
const StepNavigationGuideContent = defineComponent({
name: 'StepNavigationGuideContent',
setup() {
return () =>
h('div', { class: 'flex flex-wrap items-center justify-start' }, [
h('div', { class: 'text-yellow-400' }, [
'点击',
h(
'button',
{
class:
'w-10 h-10 bg-blue-500/80 rounded-full inline-flex items-center justify-center text-white transition-all duration-200',
title: '步骤导航',
},
[
h(
'svg',
{
width: '20',
height: '20',
viewBox: '0 0 20 20',
fill: 'none',
},
[
h('path', {
d: 'M3 4H17M3 8H17M3 12H17M3 16H17',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
}),
],
),
],
),
'可以切换不同的学习内容!',
]),
])
},
})
const introGuideSteps = computed<GuideStep[]>(() => {
if (key.value === 'profession' && !vlog.guideShown['work-profession-intro']) {
return [
{
id: 'profession-intro1',
audioSrc: getPublicPath('audio/py_1_008_2.MP3'),
highlightSelector: '.step-navigation-btn',
content: StepNavigationGuideContent,
},
]
}
return []
})
const showIntroGuide = computed(() => introGuideSteps.value.length > 0)
const handleIntroGuideDone = () => {
vlog.markGuideShown('work-profession-intro')
playing.value = true
}
// 步骤导航数据
const navigationSteps = ref([
@@ -165,7 +227,6 @@ const handleIdeaImageClick = () => {
const image = computed(() => route.query.image as string)
const key = computed(() => route.query.key as string)
const label = computed(() => route.query.label as string)
// 示例:简单锚点配置(可按需替换为真实题目数据)
// const anchors = ref([
@@ -226,6 +287,12 @@ const handleConfirmLeave = () => {
router.push(pendingLeavePath.value)
}
onMounted(() => {
if (!showIntroGuide.value) {
playing.value = true
}
})
onBeforeRouteLeave((to) => {
if (allowRouteLeave.value || !hasUnfinishedLearningVideos.value) return true
pendingLeavePath.value = to.fullPath
@@ -237,22 +304,6 @@ onBeforeRouteLeave((to) => {
<template>
<div class="w-full h-full relative bg-white/10">
<template v-if="key !== 'case' && key !== 'profession'">
<div
:class="[
'absolute left-4 top-4 z-10 w-[359px] h-[96px] bg-cover bg-center',
'flex justify-center items-center',
]"
:style="{ backgroundImage: getBackgroundImage(image) }"
>
<span
:class="[
'ml-6 text-white [text-shadow:5px_5px_5px_rgba(0,0,0,0.4)] text-5xl font-bold',
]"
>
{{ label }}
</span>
</div>
<!-- <ImgButton class="absolute left-4 top-4 z-10" :image="image"></ImgButton> -->
<ImgButton
class="absolute right-0 top-0 z-10"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@@ -262,13 +313,13 @@ onBeforeRouteLeave((to) => {
<!-- 步骤导航按钮 -->
<button
v-if="key === 'profession'"
class="absolute right-4 top-1/2 z-10 -translate-y-1/2 w-12 h-12 bg-blue-500/80 hover:bg-blue-500 rounded-full flex items-center justify-center text-white transition-all duration-200"
class="step-navigation-btn absolute right-4 top-1/2 z-10 -translate-y-1/2 w-16 h-16 bg-blue-500/80 hover:bg-blue-500 rounded-full flex items-center justify-center text-white transition-all duration-200"
@click="showStepNavigation = true"
title="步骤导航"
>
<svg
width="20"
height="20"
width="35"
height="35"
viewBox="0 0 20 20"
fill="none"
>
@@ -355,16 +406,29 @@ onBeforeRouteLeave((to) => {
<div
v-else-if="key === 'idea'"
:src="image"
class="cursor-pointer"
class="relative cursor-pointer"
@click="handleIdeaImageClick"
>
<img
:src="ideaImage"
alt=""
/>
<DownloadButton
:image-url="ideaImage"
file-name="Vlog思路提点.png"
/>
</div>
</div>
<!-- 进入专业剖析时显示引导 -->
<GuideSteps
v-if="showIntroGuide"
:steps="introGuideSteps"
position-offset-y="85%"
@complete="handleIntroGuideDone"
@skip="handleIntroGuideDone"
/>
<!-- 引导与高亮 -->
<GuideSteps
v-if="status"
@@ -51,6 +51,7 @@ const items = computed(() => [
activeImage: 'work/选中_带三角',
inactiveImage: 'work/未选中_带三角',
statusImage: getStatusImage('case'),
completed: vlog.workVideosWatched.case,
},
{
key: 'profession',
@@ -58,6 +59,7 @@ const items = computed(() => [
activeImage: 'work/选中_带三角',
inactiveImage: 'work/未选中_带三角',
statusImage: getStatusImage('profession'),
completed: vlog.workVideosWatched.profession,
},
{
key: 'idea',
@@ -65,6 +67,7 @@ const items = computed(() => [
activeImage: 'work/选中_带三角',
inactiveImage: 'work/未选中_带三角',
statusImage: getStatusImage('idea'),
completed: vlog.workVideosWatched.idea,
},
])