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