forked from zhongyidian/super_video
feat: 完善实战进度与技能树联动
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
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"
|
||||
>
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
// 视频播放状态
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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>
|
||||
+131
@@ -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>
|
||||
+21
@@ -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
-1
@@ -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'
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
])
|
||||
|
||||
|
||||
Reference in new issue
Block a user