feat: 完善课程学习与技能树体验

完善课程资源、学习进度与技能树解锁体验,统一视频播放与引导相关交互。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
zhongyidianandClaude Opus 4.7 committed 2026-05-24 10:30:18 +08:00
1 parent 6f289959be
commit 3e258b87d9
314 files changed
+7020 -3386

No files matched your search

+136
View File
@@ -0,0 +1,136 @@
<template>
<Transition name="loading-fade">
<div
v-if="visible"
class="fixed inset-0 z-[10000] flex justify-center"
>
<img
:src="getStaticImage('components/loading/背景图层')"
class="absolute inset-0 w-full h-full object-cover"
alt=""
/>
<div class="mt-60 w-[80%] flex flex-col gap-20 z-10">
<div class="ml-40 relative z-10 flex items-center">
<img
:src="getStaticImage('components/loading/精彩内容即将呈现')"
class="object-contain"
alt="精彩内容即将呈现"
/>
<div class="inline-flex items-center gap-2 mt-12">
<img
v-for="i in 6"
:key="i"
:src="getStaticImage('components/loading/省略号')"
class="loading-dot"
:style="{ animationDelay: `${(i - 1) * 0.8}s` }"
alt=""
/>
</div>
</div>
<div class="relative w-full">
<div
class="absolute transition-all duration-300 ease-linear flex w-max flex-col items-center"
:style="{
left: `${progress}%`,
transform: 'translate(-50%, 30%)',
bottom: '0px',
}"
>
<img
class="w-[208px] h-[233px] max-w-none shrink-0"
:src="getStaticImage('components/loading/小龙人')"
alt=""
/>
<p class="progress-text">
{{ progressText }}
</p>
</div>
<div
class="w-full overflow-hidden bg-no-repeat py-2 px-[6px]"
:style="{
backgroundImage: `url(${getStaticImage('components/loading/进度条底板')})`,
backgroundSize: '100% 100%',
height: '78px',
}"
>
<div
class="h-full bg-no-repeat transition-all duration-300 ease-linear"
:style="{
width: `${progress}%`,
height: '64px',
backgroundImage: `url(${getStaticImage('components/loading/进度条亮')})`,
backgroundSize: 'auto 100%',
}"
/>
</div>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { getStaticImage } from '@/utils'
defineOptions({
name: 'HomeLoadingOverlay',
})
const props = withDefaults(
defineProps<{
visible: boolean
progress?: number
}>(),
{
progress: 0,
},
)
const progressText = computed(() => `${Math.round(props.progress)}%`)
</script>
<style scoped>
.loading-dot {
animation: dotPulse 0.9s ease-in-out infinite;
}
@keyframes dotPulse {
0%,
100% {
opacity: 0.15;
transform: scale(0.75);
}
50% {
opacity: 1;
transform: scale(1.25);
}
}
.progress-text {
font-size: 3rem;
font-weight: bold;
color: #fff;
-webkit-text-stroke: 4px #3394f4;
paint-order: stroke fill;
text-shadow:
0 0 8px #55b9f4,
0 0 16px #55b9f4,
0 0 32px #55b9f4,
0 0 48px #55b9f4;
letter-spacing: 0.1em;
margin-top: 0.25rem;
}
.loading-fade-enter-active,
.loading-fade-leave-active {
transition: opacity 0.4s ease;
}
.loading-fade-enter-from,
.loading-fade-leave-to {
opacity: 0;
}
</style>
@@ -44,7 +44,7 @@ interface Emits {
}
const props = withDefaults(defineProps<Props>(), {
modelValue: 'vlog'
modelValue: 'vlog',
})
const emit = defineEmits<Emits>()
@@ -63,14 +63,15 @@ const moduleConfigs: ModuleConfig[] = [
className: 'left-10 bottom-[240px] w-[500px] h-[100px] -rotate-[15deg]',
position: 'left-10 bottom-[240px]',
size: 'w-[500px] h-[100px]',
transform: '-rotate-[15deg]'
}
transform: '-rotate-[15deg]',
},
},
{
type: 'interview',
normalImage: IMAGE_PATHS.ACTION.MODULE_BTN2,
selectedImage: IMAGE_PATHS.ACTION.SELECTED_BTN2,
className: 'w-[1087px] h-[822px] left-[calc(50%+50px)] -translate-x-1/2 -bottom-4',
className:
'w-[1087px] h-[822px] left-[calc(50%+50px)] -translate-x-1/2 -bottom-4',
maskConfig: {
/**
* width: 200px;
@@ -82,8 +83,8 @@ const moduleConfigs: ModuleConfig[] = [
'left-[6em] bottom-[60px] w-[850px] h-[153px] bg-transparent [clip-path:polygon(50%_0%,_0%_100%,_100%_100%)]',
position: 'left-[16em] bottom-[50px]',
size: 'w-[500px] h-[100px]',
transform: ''
}
transform: '',
},
},
{
type: 'film',
@@ -91,12 +92,13 @@ const moduleConfigs: ModuleConfig[] = [
selectedImage: IMAGE_PATHS.ACTION.SELECTED_BTN3,
className: 'w-[943px] h-[822px] right-[320px] -bottom-4',
maskConfig: {
className: 'right-[0em] bottom-[100px] w-[520px] h-[400px] rotate-[19deg]',
className:
'right-[0em] bottom-[100px] w-[520px] h-[260px] rotate-[19deg]',
position: 'right-[0em] bottom-[200px]',
size: 'w-[420px] h-[100px]',
transform: 'rotate-[15deg]'
}
}
transform: 'rotate-[15deg]',
},
},
]
// 选择模块
@@ -111,9 +113,9 @@ const selectModule = (module: ModuleType) => {
// 监听外部值变化
watch(
() => props.modelValue,
newValue => {
(newValue) => {
selectedModule.value = newValue
}
},
)
</script>
+231 -67
View File
@@ -1,6 +1,6 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { getBackgroundImage } from '@/utils'
import { computed, ref } from 'vue'
import { getBackgroundImage, getStaticImage } from '@/utils'
import {
useBasicElementsStore,
useFilmStore,
@@ -10,10 +10,11 @@ import {
import {
AnchoredProgressBar,
ImgButton,
type ProgressAnchor,
computeSegmentLayout,
type ProgressSegment,
} from '@/components'
import {
studyModuleAnchors,
studyProgressDisplayConfig,
type StudyProgressModuleKey,
} from './studyProgressConfig'
import {
@@ -26,10 +27,13 @@ defineOptions({
name: 'StudyProgressPopup',
})
type ProductionModuleKey = Exclude<StudyProgressModuleKey, 'basic'>
type StudyProgressItem = {
key: StudyProgressModuleKey
progress: number
anchors: ProgressAnchor[]
segments: ProgressSegment[]
detailSegments?: ProgressSegment[]
}
const visible = defineModel<boolean>({ default: false })
@@ -39,17 +43,25 @@ const vlog = useVlogStore()
const interview = useInterviewStore()
const film = useFilmStore()
const rowPositions = {
basic: { height: 168 },
vlog: { height: 148 },
interview: { height: 146 },
film: { height: 146 },
} as const
const ROW_HEIGHT = {
basic: 168,
vlog: 148,
interview: 146,
film: 146,
} satisfies Record<StudyProgressModuleKey, number>
const PRODUCTION_KEYS = new Set<StudyProgressModuleKey>([
'vlog',
'interview',
'film',
])
const isProduction = (
key: StudyProgressModuleKey,
): key is ProductionModuleKey => PRODUCTION_KEYS.has(key)
const labelTextShadow =
'0 0 5px rgba(255,255,255,0.95), 0 0 10px rgba(54,111,255,0.75), 0 3px 0 rgba(0, 48, 151, 0.75)'
const totalTextShadow = '4px 5px 0 rgba(40, 130, 253, 0.75)'
const childTextShadow =
'0 0 6px rgba(54,250,255,0.9), 0 2px 0 rgba(0, 68, 151, 0.7)'
const basicProgress = computed(() =>
getBasicElementsProgress(
@@ -59,12 +71,16 @@ const basicProgress = computed(() =>
),
)
const vlogProgress = computed(() =>
getProductionModuleProgress('vlog', vlog.viewed, vlog.workVideosWatched),
getProductionModuleProgress(
'vlog',
vlog.isRouteViewed,
vlog.workVideosWatched,
),
)
const interviewProgress = computed(() =>
getProductionModuleProgress(
'interview',
interview.viewed,
interview.isRouteViewed,
interview.workVideosWatched,
),
)
@@ -84,32 +100,68 @@ const totalProgress = computed(() =>
filmProgress.value,
]),
)
const buildItem = (
key: StudyProgressModuleKey,
progress: number,
): StudyProgressItem => {
const config = studyProgressDisplayConfig[key]
return {
key,
progress,
segments: config.segments,
detailSegments: config.detailSegments,
}
}
const progressItems = computed<StudyProgressItem[]>(() => [
{
key: 'basic',
progress: basicProgress.value,
anchors: studyModuleAnchors.basic,
},
{
key: 'vlog',
progress: vlogProgress.value,
anchors: studyModuleAnchors.vlog,
},
{
key: 'interview',
progress: interviewProgress.value,
anchors: studyModuleAnchors.interview,
},
{
key: 'film',
progress: filmProgress.value,
anchors: studyModuleAnchors.film,
},
buildItem('basic', basicProgress.value),
buildItem('vlog', vlogProgress.value),
buildItem('interview', interviewProgress.value),
buildItem('film', filmProgress.value),
])
const activeDetailKey = ref<ProductionModuleKey | null>(null)
const closeTimer = ref<number>()
const openDetail = (key: StudyProgressModuleKey) => {
if (!isProduction(key)) return
window.clearTimeout(closeTimer.value)
activeDetailKey.value = key
}
const scheduleClose = () => {
window.clearTimeout(closeTimer.value)
closeTimer.value = window.setTimeout(() => {
activeDetailKey.value = null
}, 120)
}
const toggleDetail = (key: StudyProgressModuleKey) => {
if (!isProduction(key)) return
window.clearTimeout(closeTimer.value)
activeDetailKey.value = activeDetailKey.value === key ? null : key
}
const closeDetail = () => {
window.clearTimeout(closeTimer.value)
activeDetailKey.value = null
}
const handleClose = () => {
closeDetail()
visible.value = false
}
const ZOOM_SEGMENT_ID = 'zoom'
const NODE_IMAGE = 'home/stuy/节点圆圈'
const nodeImageSrc = getStaticImage(NODE_IMAGE)
const detailSegmentLayout = (segments: ProgressSegment[]) =>
computeSegmentLayout(segments, 20, 85)
const zoomPhaseProgress = (progress: number) =>
Math.round(Math.min(100, Math.max(0, ((progress - 20) / 65) * 100)))
</script>
<template>
@@ -125,15 +177,16 @@ const handleClose = () => {
v-if="visible"
class="fixed inset-0 z-[100] bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: getBackgroundImage('home/stuy/学习进度底图') }"
@click="closeDetail"
>
<ImgButton
class="absolute right-0 top-0 z-10"
@click="handleClose"
:image="'components/close'"
@click="handleClose"
/>
<div
class="absolute left-1/2 top-[14%] flex -translate-x-1/2 items-end text-white font-[1000] tracking-widest pr-10"
class="absolute left-1/2 top-[14%] flex -translate-x-1/2 items-end pr-10 text-white font-[1000] tracking-widest"
:style="{ textShadow: totalTextShadow }"
>
<span class="text-[clamp(36px,4.5vw,72px)] leading-none">总进度</span>
@@ -143,40 +196,151 @@ const handleClose = () => {
</div>
<div
class="w-[1580px] mx-auto mt-[215px] flex flex-col pr-16 pl-64 gap-2"
class="mx-auto mt-[215px] flex w-[1580px] flex-col gap-2 pl-[254px] pr-[60px]"
@click.stop
>
<AnchoredProgressBar
<div
v-for="item in progressItems"
:key="item.key"
class="relative w-full"
:style="{ height: `${rowPositions[item.key].height}px` }"
:progress="item.progress"
:anchors="item.anchors"
node-image="home/stuy/节点圆圈"
:label-class="
item.key === 'basic'
? 'text-[clamp(24px,2.2vw,42px)] text-white font-[1000] tracking-wider'
: 'text-[clamp(24px,2.1vw,40px)] text-white font-[1000] tracking-wider'
"
child-label-class="text-[clamp(20px,1.75vw,34px)] text-[#43f3f2] font-[1000] tracking-wider"
percent-class="text-[clamp(22px,2vw,38px)] text-white font-[1000] italic"
label-top="2%"
child-label-top="34%"
bar-top="58%"
bar-height="18%"
marker-top="50%"
marker-height="34%"
marker-width="1%"
child-marker-top="53%"
child-marker-height="28%"
child-marker-width="0.3%"
node-top="41%"
node-height="52%"
percent-top="78%"
:label-style="{ textShadow: labelTextShadow }"
:child-label-style="{ textShadow: childTextShadow }"
:percent-style="{ textShadow: labelTextShadow }"
/>
:style="{ height: `${ROW_HEIGHT[item.key]}px` }"
@mouseleave="scheduleClose"
>
<div
v-for="seg in item.segments"
:key="seg.id"
class="absolute whitespace-nowrap font-[1000] tracking-wider text-white text-[clamp(28px,2.4vw,48px)]"
:class="[
item.key === 'basic' ? '-translate-x-1/2' : '',
item.key !== 'basic' && seg.id === ZOOM_SEGMENT_ID
? 'cursor-pointer transition-transform duration-200 hover:scale-105'
: '',
]"
:style="{
top: item.key === 'basic' ? '9%' : '7%',
left:
item.key === 'basic'
? `${(seg.start + seg.end) / 2}%`
: `${seg.start}%`,
textShadow: labelTextShadow,
}"
@mouseenter="seg.id === ZOOM_SEGMENT_ID && openDetail(item.key)"
@mouseleave="seg.id === ZOOM_SEGMENT_ID && scheduleClose()"
@click.stop="seg.id === ZOOM_SEGMENT_ID && toggleDetail(item.key)"
>
{{ seg.label }}
</div>
<AnchoredProgressBar
class="absolute inset-x-0 top-1/2 h-[22%] -translate-y-1/2"
:progress="item.progress"
:segments="item.segments"
segment-class="bg-white/10"
/>
<img
:src="nodeImageSrc"
class="absolute -translate-x-1/2 transition-all duration-700 ease-out"
:style="{
top: '24%',
height: '52%',
left: `${Math.min(100, Math.max(0, item.progress))}%`,
}"
alt=""
/>
<div
class="absolute -translate-x-1/2 whitespace-nowrap font-[1000] tracking-widest text-white text-[clamp(22px,2vw,38px)] transition-all duration-700 ease-out"
:style="{
top: '78%',
left: `${Math.min(100, Math.max(0, item.progress))}%`,
textShadow: totalTextShadow,
}"
>
{{ Math.round(item.progress) }}%
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 translate-y-2 scale-95"
enter-to-class="opacity-100 translate-y-0 scale-100"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100 translate-y-0 scale-100"
leave-to-class="opacity-0 translate-y-2 scale-95"
>
<div
v-if="activeDetailKey === item.key && item.detailSegments"
class="absolute bottom-full left-[52.5%] z-20 mb-4 w-[860px] -translate-x-1/2"
@mouseenter="openDetail(item.key)"
@mouseleave="scheduleClose"
@click.stop
>
<div
class="relative rounded-[40px] border-[3px] border-[#43f3f2] bg-gradient-to-br from-[#1e57c8]/80 via-[#0f3da8]/80 to-[#0a2a82]/80 px-12 pb-8 pt-7 shadow-[0_0_44px_rgba(67,243,242,0.7),inset_0_0_24px_rgba(67,243,242,0.28)] backdrop-blur"
>
<div
class="pointer-events-none absolute inset-[6px] rounded-[34px] border border-white/25"
></div>
<div
class="mb-7 flex items-baseline justify-between"
:style="{ textShadow: labelTextShadow }"
>
<span
class="text-[clamp(28px,2.3vw,42px)] font-[1000] tracking-wider text-white"
>
制作变焦镜
</span>
<span
class="text-[clamp(22px,1.9vw,34px)] font-[1000] tracking-widest text-white"
:style="{ textShadow: totalTextShadow }"
>
{{ zoomPhaseProgress(item.progress) }}%
</span>
</div>
<div class="relative h-[120px] w-full">
<div
v-for="seg in detailSegmentLayout(item.detailSegments)"
:key="seg.id"
class="absolute -translate-x-1/2 whitespace-nowrap text-[clamp(22px,1.85vw,34px)] font-[1000] tracking-wider text-white"
:style="{
top: '4%',
left: `${seg.center}%`,
textShadow: labelTextShadow,
}"
>
{{ seg.label }}
</div>
<AnchoredProgressBar
class="absolute inset-x-0 top-1/2 h-[28%] -translate-y-1/2"
:min="20"
:max="85"
:progress="Math.min(85, Math.max(20, item.progress))"
:segments="item.detailSegments"
track-class="bg-[#021543]/85 rounded-[6px] shadow-[inset_0_0_14px_rgba(0,0,0,0.55)] ring-1 ring-white/25"
fill-class="bg-gradient-to-r from-[#7df8ff] via-[#43f3f2] to-[#1ed7ff] shadow-[0_0_24px_rgba(67,243,242,0.95)]"
segment-class="bg-white/5"
/>
<img
:src="nodeImageSrc"
class="absolute -translate-x-1/2 transition-all duration-700 ease-out"
:style="{
top: '24%',
height: '52%',
left: `${
((Math.min(85, Math.max(20, item.progress)) - 20) /
65) *
100
}%`,
}"
alt=""
/>
</div>
</div>
</div>
</Transition>
</div>
</div>
</div>
</Transition>
@@ -1,31 +1,41 @@
import type { ProgressAnchor } from '@/components'
import type { ProgressSegment } from '@/components'
export type StudyProgressModuleKey = 'basic' | 'vlog' | 'interview' | 'film'
const productionAnchors: ProgressAnchor[] = [
{ id: 'task', label: '任务取景框', value: 0 },
{
id: 'zoom',
label: '制作变焦镜',
value: 20,
children: [
{ id: 'prepare', label: '前期准备', value: 20 },
{ id: 'shooting', label: '中期拍摄', value: 42 },
{ id: 'editing', label: '后期剪辑', value: 64 }
]
},
{ id: 'practice', label: '实战操作台', value: 85 },
{ id: 'done', label: '', value: 100 }
export type StudyProgressDisplayConfig = {
segments: ProgressSegment[]
detailSegments?: ProgressSegment[]
}
const basicSegments: ProgressSegment[] = [
{ id: 'three-elements', label: '三大基础要素', start: 0, end: 33.33 },
{ id: 'shooting-skills', label: '基础拍摄技巧', start: 33.33, end: 66.66 },
{ id: 'editing-skills', label: '短视频剪辑技巧', start: 66.66, end: 100 },
]
export const studyModuleAnchors: Record<StudyProgressModuleKey, ProgressAnchor[]> = {
basic: [
{ id: 'three-elements', label: '三大基础要素', value: 0 },
{ id: 'shooting-skills', label: '基础拍摄技巧', value: 33.33 },
{ id: 'editing-skills', label: '短视频剪辑技巧', value: 66.66 },
{ id: 'done', label: '', value: 100 }
],
vlog: productionAnchors,
interview: productionAnchors,
film: productionAnchors
const productionSegments: ProgressSegment[] = [
{ id: 'task', label: '任务取景框', start: 0, end: 20 },
{ id: 'zoom', label: '制作变焦镜', start: 20, end: 85 },
{ id: 'practice', label: '实战操作台', start: 85, end: 100 },
]
export const zoomDetailSegments: ProgressSegment[] = [
{ id: 'prepare', label: '前期准备', start: 20, end: 42 },
{ id: 'shooting', label: '中期拍摄', start: 42, end: 64 },
{ id: 'editing', label: '后期剪辑', start: 64, end: 85 },
]
const productionDisplayConfig: StudyProgressDisplayConfig = {
segments: productionSegments,
detailSegments: zoomDetailSegments,
}
export const studyProgressDisplayConfig: Record<
StudyProgressModuleKey,
StudyProgressDisplayConfig
> = {
basic: { segments: basicSegments },
vlog: productionDisplayConfig,
interview: productionDisplayConfig,
film: productionDisplayConfig,
}
+6 -102
View File
@@ -1,102 +1,6 @@
import { moduleStepMap } from '@/config/moduleStepMap'
export type ProductionModuleKey = 'vlog' | 'interview' | 'film'
type StepKey = 1 | 2 | 3
const countViewedRoutes = (
routes: readonly string[],
viewed: Record<string, boolean>,
) => {
if (routes.length === 0) return 0
return routes.filter((routeName) => viewed[routeName]).length
}
const getStepProgress = (
routes: readonly string[],
viewed: Record<string, boolean>,
) => {
if (routes.length === 0) return 0
return countViewedRoutes(routes, viewed) / routes.length
}
const WORK_COMPLETION_KEYS = ['case', 'profession', 'idea'] as const
const getWorkProgress = (workVideosWatched: Record<string, boolean>) =>
countViewedRoutes(WORK_COMPLETION_KEYS, workVideosWatched) /
WORK_COMPLETION_KEYS.length
export const getProductionModuleProgress = (
moduleKey: ProductionModuleKey,
viewed: Record<string, boolean>,
workVideosWatched: Record<string, boolean> = {},
) => {
const stepRoutes = moduleStepMap[moduleKey]
const taskProgress = getWorkProgress(workVideosWatched) * 20
const stepProgress = (step: StepKey) =>
getStepProgress(stepRoutes[step], viewed)
const step1Progress = stepProgress(1) * 22
const step2Progress = stepProgress(2) * 22
const step3Progress = stepProgress(3) * 21
const progress = taskProgress + step1Progress + step2Progress + step3Progress
if (
progress >= 85 &&
step1Progress === 22 &&
step2Progress === 22 &&
step3Progress === 21
) {
return 100
}
return Math.round(progress)
}
const BASIC_ELEMENTS_VIDEO_KEYS = [
'basic-composition:step1',
'basic-composition:step2',
'basic-light:step2',
'basic-light:step3',
'basic-light:step4',
'basic-camera-movement:step2',
'basic-camera-movement:step3',
'basic-shooting-skills:step2',
'basic-shooting-skills:step3',
'basic-shooting-skills:step4',
'basic-shooting-skills:step5',
'basic-shooting-skills:step6',
'basic-shooting-skills:step7',
'basic-short-video-editing-skills:step2',
'basic-short-video-editing-skills:step3',
'basic-short-video-editing-skills:step4',
'basic-short-video-editing-skills:step5',
'basic-short-video-editing-skills:step6',
'basic-short-video-editing-skills:step7',
'basic-short-video-editing-skills:step8',
'basic-short-video-editing-skills:step9',
'basic-short-video-editing-skills:step10',
'basic-short-video-editing-skills:step11',
] as const
export const getBasicElementsProgress = (
unlockProgress: number,
isUnlocked: boolean,
learningVideosWatched: Record<string, boolean> = {},
) => {
if (isUnlocked) return 100
const watchedProgress =
(countViewedRoutes(BASIC_ELEMENTS_VIDEO_KEYS, learningVideosWatched) /
BASIC_ELEMENTS_VIDEO_KEYS.length) *
100
return Math.round(
Math.max(0, Math.min(100, Math.max(unlockProgress, watchedProgress))),
)
}
export const getOverallStudyProgress = (progresses: readonly number[]) => {
if (progresses.length === 0) return 0
const total = progresses.reduce((sum, progress) => sum + progress, 0)
return Math.round(total / progresses.length)
}
export {
getBasicElementsProgress,
getOverallStudyProgress,
getProductionModuleProgress,
type ProductionModuleKey,
} from '@/config/studyProgress'