feat: 完善视频学习进度缓存

完善新手导航和三大创作模块的视频完成缓存、未完成提示与学习进度计算,确保进度按实际学习资源完成情况更新。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
zhongyidianandClaude Opus 4.7 committed 2026-05-11 09:05:29 +08:00
1 parent 1f8723aabd
commit 6f289959be
63 files changed
+3139 -1126

No files matched your search

+68 -7
View File
@@ -11,7 +11,7 @@ import {
import { ImgButton } from '@/components/Button'
import { ModuleSwitcher } from './components/ModuleSwitcher'
import StudyProgressPopup from './components/StudyProgressPopup.vue'
import { computed, ref, onMounted } from 'vue'
import { computed, ref, nextTick, onMounted } from 'vue'
import { useRouter, type RouteRecordName } from 'vue-router'
import {
useUserStore,
@@ -22,6 +22,11 @@ import {
} from '@/stores'
import { GuideSteps } from '@/components/Guide'
import type { GuideStep } from '@/components/Guide/types'
import {
getBasicElementsProgress,
getOverallStudyProgress,
getProductionModuleProgress,
} from './components/studyProgressUtils'
type GuideChildNode = {
id: number
@@ -87,7 +92,7 @@ const homeGuideSteps: GuideStep[] = [
const unlockedHomeGuideSteps: GuideStep[] = [
{
id: 'home-guide-welcome',
content: '欢迎来到新手村!在这里,我会带着你解锁短视频创作的核心基本功!',
content: '欢迎来到新手模块!在这里,我会带着你解锁短视频创作的核心基本功!',
audioSrc: getPublicPath('audio/py_1_001.MP3'),
},
{
@@ -108,6 +113,7 @@ const unlockedHomeGuideSteps: GuideStep[] = [
const elementGuideSteps: GuideStep[] = [
{
id: 'home-guide-start-element',
highlightSelector: '.child-node-buttons',
content: '选择任意要素开始学习~',
positionOffsetY: '20%',
audioSrc: getPublicPath('audio/py_1_004.MP3'),
@@ -210,6 +216,32 @@ const selectedImage = computed(() => {
return getBackgroundImage(imageMap[selectedModule.value])
})
const filmViewed = computed(() => ({
...film.viewed,
FilmOnsiteShooting: film.allOnsiteScenesViewed,
FilmEditing: film.allEditingVideosWatched,
}))
const totalStudyPercent = computed(() =>
getOverallStudyProgress([
getBasicElementsProgress(
basicElements.basicElementsUnlockProgress,
basicElements.isBasicElementsUnlock,
basicElements.learningVideosWatched,
),
getProductionModuleProgress('vlog', vlog.viewed, vlog.workVideosWatched),
getProductionModuleProgress(
'interview',
interview.viewed,
interview.workVideosWatched,
),
getProductionModuleProgress(
'film',
filmViewed.value,
film.workVideosWatched,
),
]),
)
const isSkillDisabled = computed(() => !hasFinishedActionGuide.value)
const handleOpenStudyProgress = () => {
@@ -291,6 +323,7 @@ const handleNodeClick = (node: GuideNode, event?: MouseEvent) => {
event?.stopPropagation()
if (!node.hasChildren) {
showChildrenNodeId.value = null
if (node.routeName) {
router.push({ name: node.routeName })
}
@@ -304,7 +337,11 @@ const handleNodeClick = (node: GuideNode, event?: MouseEvent) => {
showChildrenNodeId.value = node.id
if (activeActionGuide.value !== 'unlocked' && !hasFinishedActionGuide.value) {
activeActionGuide.value = 'element'
nextTick(() => {
requestAnimationFrame(() => {
activeActionGuide.value = 'element'
})
})
}
}
@@ -365,6 +402,10 @@ onMounted(() => {
<StudyProgressPopup v-model="studyProgressVisible" />
<div class="absolute top-[7.5rem] left-48 pointer-events-none">
<span class="text-5xl italic text-white">{{ totalStudyPercent }}%</span>
</div>
<div
class="absolute top-4 left-4 flex items-center text-white text-xl bg-black/10 px-4 py-2 rounded-xl"
>
@@ -451,7 +492,10 @@ onMounted(() => {
@click="handleClickOutside"
>
<!-- 标题区域 -->
<div class="text-center mt-10 mb-8">
<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"
>
@@ -485,6 +529,10 @@ onMounted(() => {
stroke="#7bfffd"
stroke-width="5"
stroke-linecap="round"
class="transition-opacity duration-300"
:class="
showChildrenNodeId !== null ? 'opacity-35' : 'opacity-100'
"
/>
<!-- 节点2 -> 节点3 -->
<line
@@ -495,6 +543,10 @@ onMounted(() => {
stroke="#7bfffd"
stroke-width="5"
stroke-linecap="round"
class="transition-opacity duration-300"
:class="
showChildrenNodeId !== null ? 'opacity-35' : 'opacity-100'
"
/>
<!-- 节点3 -> 右侧装饰线 -->
<line
@@ -505,6 +557,10 @@ onMounted(() => {
stroke="#7bfffd"
stroke-width="5"
stroke-linecap="round"
class="transition-opacity duration-300"
:class="
showChildrenNodeId !== null ? 'opacity-35' : 'opacity-100'
"
/>
</svg>
@@ -512,7 +568,12 @@ onMounted(() => {
<div
v-for="node in nodes"
:key="node.id"
class="absolute transform -translate-x-1/2 -translate-y-1/2"
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)"
>
@@ -547,10 +608,10 @@ onMounted(() => {
<Transition name="child-node-pop">
<div
v-if="node.hasChildren && showChildrenNodeId === node.id"
class="child-node-buttons btn absolute -top-48 left-1/2 z-20 flex -translate-x-1/2 flex-col items-center"
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(8,40,58,0.82)] px-3 py-2 shadow-[0_0_0_1px_rgba(123,255,253,0.28),0_0_20px_rgba(123,255,253,0.28)] after:absolute after:left-1/2 after:top-[calc(100%-1px)] after:h-0 after:w-0 after:-translate-x-1/2 after:border-x-[12px] after:border-t-[14px] after:border-x-transparent after:border-t-[rgba(8,40,58,0.82)]"
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"
@@ -1,13 +1,29 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { getBackgroundImage } from '@/utils'
import { useBasicElementsStore, useFilmStore, useInterviewStore, useVlogStore } from '@/stores'
import { AnchoredProgressBar, ImgButton, type ProgressAnchor } from '@/components'
import { studyModuleAnchors, type StudyProgressModuleKey } from './studyProgressConfig'
import { getProductionModuleProgress } from './studyProgressUtils'
import {
useBasicElementsStore,
useFilmStore,
useInterviewStore,
useVlogStore,
} from '@/stores'
import {
AnchoredProgressBar,
ImgButton,
type ProgressAnchor,
} from '@/components'
import {
studyModuleAnchors,
type StudyProgressModuleKey,
} from './studyProgressConfig'
import {
getBasicElementsProgress,
getOverallStudyProgress,
getProductionModuleProgress,
} from './studyProgressUtils'
defineOptions({
name: 'StudyProgressPopup'
name: 'StudyProgressPopup',
})
type StudyProgressItem = {
@@ -27,42 +43,68 @@ const rowPositions = {
basic: { height: 168 },
vlog: { height: 148 },
interview: { height: 146 },
film: { height: 146 }
film: { height: 146 },
} as const
const labelTextShadow =
'0 0 5px rgba(255,255,255,0.95), 0 0 10px rgba(54,111,255,0.75), 0 3px 0 rgba(0, 48, 151, 0.75)'
const totalTextShadow = '4px 5px 0 rgba(40, 130, 253, 0.75)'
const childTextShadow = '0 0 6px rgba(54,250,255,0.9), 0 2px 0 rgba(0, 68, 151, 0.7)'
const childTextShadow =
'0 0 6px rgba(54,250,255,0.9), 0 2px 0 rgba(0, 68, 151, 0.7)'
const basicProgress = computed(() =>
basicElements.isBasicElementsUnlock ? 100 : basicElements.basicElementsUnlockProgress
getBasicElementsProgress(
basicElements.basicElementsUnlockProgress,
basicElements.isBasicElementsUnlock,
basicElements.learningVideosWatched,
),
)
const vlogProgress = computed(() =>
getProductionModuleProgress('vlog', vlog.viewed, vlog.workVideosWatched),
)
const interviewProgress = computed(() =>
getProductionModuleProgress(
'interview',
interview.viewed,
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 totalProgress = computed(() => {
const total =
basicProgress.value + vlog.progressPercent + interview.progressPercent + film.progressPercent
return Math.round(total / 4)
})
const progressItems = computed<StudyProgressItem[]>(() => [
{
key: 'basic',
progress: basicProgress.value,
anchors: studyModuleAnchors.basic
anchors: studyModuleAnchors.basic,
},
{
key: 'vlog',
progress: getProductionModuleProgress('vlog', vlog.viewed),
anchors: studyModuleAnchors.vlog
progress: vlogProgress.value,
anchors: studyModuleAnchors.vlog,
},
{
key: 'interview',
progress: getProductionModuleProgress('interview', interview.viewed),
anchors: studyModuleAnchors.interview
progress: interviewProgress.value,
anchors: studyModuleAnchors.interview,
},
{
key: 'film',
progress: getProductionModuleProgress('film', film.viewed),
anchors: studyModuleAnchors.film
}
progress: filmProgress.value,
anchors: studyModuleAnchors.film,
},
])
const handleClose = () => {
@@ -95,10 +137,14 @@ const handleClose = () => {
: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>
<span class="text-[clamp(36px,4.5vw,72px)] leading-none">
{{ totalProgress }}%
</span>
</div>
<div class="w-[1580px] mx-auto mt-[215px] flex flex-col pr-16 pl-64 gap-2">
<div
class="w-[1580px] mx-auto mt-[215px] flex flex-col pr-16 pl-64 gap-2"
>
<AnchoredProgressBar
v-for="item in progressItems"
:key="item.key"
+82 -21
View File
@@ -1,41 +1,102 @@
import { moduleStepMap } from '@/config/moduleStepMap'
type ProductionModuleKey = 'vlog' | 'interview' | 'film'
export type ProductionModuleKey = 'vlog' | 'interview' | 'film'
type StepKey = 1 | 2 | 3
const countViewedRoutes = (routes: readonly string[], viewed: Record<string, boolean>) => {
const countViewedRoutes = (
routes: readonly string[],
viewed: Record<string, boolean>,
) => {
if (routes.length === 0) return 0
return routes.filter(routeName => viewed[routeName]).length
return routes.filter((routeName) => viewed[routeName]).length
}
const getStepProgress = (routes: readonly string[], viewed: Record<string, boolean>) => {
const getStepProgress = (
routes: readonly string[],
viewed: Record<string, boolean>,
) => {
if (routes.length === 0) return 0
return countViewedRoutes(routes, viewed) / routes.length
}
const interpolate = (start: number, end: number, progress: number) =>
start + (end - start) * progress
const WORK_COMPLETION_KEYS = ['case', 'profession', 'idea'] as const
const getWorkProgress = (workVideosWatched: Record<string, boolean>) =>
countViewedRoutes(WORK_COMPLETION_KEYS, workVideosWatched) /
WORK_COMPLETION_KEYS.length
export const getProductionModuleProgress = (
moduleKey: ProductionModuleKey,
viewed: Record<string, boolean>
viewed: Record<string, boolean>,
workVideosWatched: Record<string, boolean> = {},
) => {
const stepRoutes = moduleStepMap[moduleKey]
const hasAnyViewed = Object.values(stepRoutes).some(routes =>
routes.some(routeName => viewed[routeName])
)
const taskProgress = getWorkProgress(workVideosWatched) * 20
const stepProgress = (step: StepKey) =>
getStepProgress(stepRoutes[step], viewed)
const step1Progress = stepProgress(1) * 22
const step2Progress = stepProgress(2) * 22
const step3Progress = stepProgress(3) * 21
const progress = taskProgress + step1Progress + step2Progress + step3Progress
if (!hasAnyViewed) return 0
if (
progress >= 85 &&
step1Progress === 22 &&
step2Progress === 22 &&
step3Progress === 21
) {
return 100
}
const stepProgress = (step: StepKey) => getStepProgress(stepRoutes[step], viewed)
const step1Progress = stepProgress(1)
const step2Progress = stepProgress(2)
const step3Progress = stepProgress(3)
if (step1Progress < 1) return Math.round(interpolate(20, 42, step1Progress))
if (step2Progress < 1) return Math.round(interpolate(42, 64, step2Progress))
if (step3Progress < 1) return Math.round(interpolate(64, 85, step3Progress))
return 100
return Math.round(progress)
}
const BASIC_ELEMENTS_VIDEO_KEYS = [
'basic-composition:step1',
'basic-composition:step2',
'basic-light:step2',
'basic-light:step3',
'basic-light:step4',
'basic-camera-movement:step2',
'basic-camera-movement:step3',
'basic-shooting-skills:step2',
'basic-shooting-skills:step3',
'basic-shooting-skills:step4',
'basic-shooting-skills:step5',
'basic-shooting-skills:step6',
'basic-shooting-skills:step7',
'basic-short-video-editing-skills:step2',
'basic-short-video-editing-skills:step3',
'basic-short-video-editing-skills:step4',
'basic-short-video-editing-skills:step5',
'basic-short-video-editing-skills:step6',
'basic-short-video-editing-skills:step7',
'basic-short-video-editing-skills:step8',
'basic-short-video-editing-skills:step9',
'basic-short-video-editing-skills:step10',
'basic-short-video-editing-skills:step11',
] as const
export const getBasicElementsProgress = (
unlockProgress: number,
isUnlocked: boolean,
learningVideosWatched: Record<string, boolean> = {},
) => {
if (isUnlocked) return 100
const watchedProgress =
(countViewedRoutes(BASIC_ELEMENTS_VIDEO_KEYS, learningVideosWatched) /
BASIC_ELEMENTS_VIDEO_KEYS.length) *
100
return Math.round(
Math.max(0, Math.min(100, Math.max(unlockProgress, watchedProgress))),
)
}
export const getOverallStudyProgress = (progresses: readonly number[]) => {
if (progresses.length === 0) return 0
const total = progresses.reduce((sum, progress) => sum + progress, 0)
return Math.round(total / progresses.length)
}