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

+147
View File
@@ -0,0 +1,147 @@
<script lang="ts" setup>
import { ref, computed } from 'vue'
import type { Component } from 'vue'
import { Overlay } from '@/components/Overlay'
import { ImgButton } from '@/components/Button'
import { Video } from '@/components/Video'
import { GuideSteps, type GuideStep } from '@/components/Guide'
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
interface CompositionBtn {
key: string
name: string
btnImage: string
images: string[]
desc: string
}
withDefaults(
defineProps<{
visible: boolean
bgImage: string
btnList: CompositionBtn[]
knowledgeCardComponent: Component
videoSrc: string
guideSteps?: GuideStep[]
showInternalGuide?: boolean
}>(),
{
guideSteps: () => [],
showInternalGuide: false,
},
)
const emit = defineEmits<{
close: []
}>()
const showKnowledgeCard = ref(false)
const showVideo = ref(false)
const currentItem = ref<CompositionBtn>({
key: '',
name: '',
btnImage: '',
images: [],
desc: '',
})
const currentCompositionKey = computed(() => {
const k = currentItem.value.key
if (k === '1' || k === '2' || k === '3') return k
return '1'
})
const handleClose = () => {
emit('close')
}
const handleItemClick = (item: CompositionBtn) => {
currentItem.value = item
showKnowledgeCard.value = true
}
</script>
<template>
<Overlay
:visible="visible"
:clickable="false"
:customStyle="{ backgroundImage: getBackgroundImage(bgImage) }"
customClass="inset-0 bg-cover bg-center bg-no-repeat"
contentClass="relative w-full h-full"
>
<ImgButton
class="absolute right-0 top-0 z-10"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
/>
<ImgButton
class="absolute left-0 top-0 z-10"
image="shooting/标题-构图指南"
/>
<div class="absolute left-28 top-[calc(50%-40px)] -translate-y-1/2">
<ImgButton
image="shooting/新手导航-视频回顾"
@click="showVideo = true"
/>
<div
class="absolute left-[38%] top-[50%] -translate-x-1/2 -translate-y-1/2 z-10 animate-bounce pointer-events-none"
>
<img
class="w-40"
:src="getStaticImage('work/小手')"
alt=""
/>
</div>
</div>
<div class="absolute top-[30%] right-10">
<ImgButton
v-for="item in btnList"
:key="item.key"
:class="`btn-item-${item.key}`"
:image="item.btnImage"
@click="handleItemClick(item)"
/>
</div>
<GuideSteps
v-if="showInternalGuide && guideSteps.length > 0"
:visible="showInternalGuide"
:steps="guideSteps"
:keyboard-navigation="true"
:highlight-padding="12"
:highlight-radius="16"
position-offset-y="85%"
/>
<component
:is="knowledgeCardComponent"
v-if="showKnowledgeCard && currentItem.key"
:visible="showKnowledgeCard"
:title="currentItem.name"
:desc="currentItem.desc"
:images="currentItem.images"
:compositionKey="currentCompositionKey"
@close="showKnowledgeCard = false"
/>
<Overlay
:visible="showVideo"
:clickable="false"
>
<Video
:src="videoSrc"
controls
:volume="1"
:muted="false"
/>
<ImgButton
class="absolute right-0 top-0 z-10"
:image="IMAGE_PATHS.COMPONENTS.CLOSE"
@click="showVideo = false"
/>
</Overlay>
</Overlay>
</template>
+1
View File
@@ -57,6 +57,7 @@
<!-- 支持字符串形式的 content -->
<div
v-else
class="whitespace-pre-line"
v-html="currentStepContent"
></div>
</GuideStepsItem>
+64 -17
View File
@@ -1,7 +1,13 @@
<template>
<Transition name="step-navigation" appear>
<Transition
name="step-navigation"
appear
>
<!-- 抽屉遮罩层 -->
<div v-if="visible" class="fixed inset-0 z-50 bg-black/30 backdrop-blur-md">
<div
v-if="visible"
class="fixed inset-0 z-50 bg-black/30 backdrop-blur-md"
>
<ImgButton
class="absolute right-0 top-0 z-[10]"
image="components/close"
@@ -20,14 +26,24 @@
@click.stop
>
<!-- 顶部分页箭头(上一页) -->
<div class="relative z-10 flex items-center justify-center pointer-events-none">
<div
class="relative z-10 flex items-center justify-center pointer-events-none"
>
<button
class="pointer-events-auto flex h-20 w-20 items-center justify-center border-none bg-transparent transition-all duration-200 disabled:cursor-not-allowed disabled:opacity-40 hover:enabled:scale-110"
@click="goPrevPage"
:disabled="!canPrevPage"
>
<svg width="64" height="64" viewBox="0 0 40 40" fill="none">
<path d="M5 30L35 30L20 6Z" fill="white" />
<svg
width="64"
height="64"
viewBox="0 0 40 40"
fill="none"
>
<path
d="M5 30L35 30L20 6Z"
fill="white"
/>
</svg>
</button>
</div>
@@ -41,20 +57,31 @@
class="step-item-bg relative flex h-[122px] w-[831px] cursor-pointer items-center px-8 py-4 transition-all duration-300 hover:-translate-x-[23%]"
:class="{
'step-item-active-bg h-[230px] w-[1034px] pl-[180px] -translate-x-[23%]':
currentPageStartIndex + index === currentStepIndex
currentPageStartIndex + index === currentStepIndex,
}"
@click="handleStepClick(index)"
>
<!-- 步骤标签 -->
<div
class="mr-4 min-w-[5rem] rounded-full px-16 py-4 text-center text-2xl font-semibold shadow-md"
class="mr-0 min-w-[5rem] rounded-full px-16 py-4 text-center text-2xl font-semibold shadow-md"
:class="getTagColor(step.label)"
>
{{ step.label }}
</div>
<!-- 步骤内容 -->
<div class="mr-4 flex-1 text-3xl font-bold leading-[1.5]">
<div
class="mr-4 flex-1 text-3xl font-bold leading-[1.5] flex items-center"
>
<img
class="pointer-events-none h-12 w-12 object-contain transition-all duration-300"
:src="
completedSteps?.[currentPageStartIndex + index]
? completedIcon
: incompleteIcon
"
alt=""
/>
{{ step.content }}
</div>
@@ -63,7 +90,7 @@
class="absolute right-[15%] z-[1] flex items-center justify-center text-6xl font-semibold text-white/30 transition-all duration-300"
:class="{
'mb-20 text-[150px] text-white drop-shadow-[5px_10px_5px_rgba(0,0,0,0.1)]':
currentPageStartIndex + index === currentStepIndex
currentPageStartIndex + index === currentStepIndex,
}"
>
{{ currentPageStartIndex + index + 1 }}
@@ -79,8 +106,16 @@
@click="goNextPage"
:disabled="!canNextPage"
>
<svg width="64" height="64" viewBox="0 0 40 40" fill="none">
<path d="M5 10L35 10L20 34Z" fill="white" />
<svg
width="64"
height="64"
viewBox="0 0 40 40"
fill="none"
>
<path
d="M5 10L35 10L20 34Z"
fill="white"
/>
</svg>
</button>
</div>
@@ -94,6 +129,8 @@
import { ImgButton } from '@/components'
import { ref, computed, watch } from 'vue'
import { getTagColor } from '@/utils'
import completedIcon from '@/assets/images/midterm/完成.png'
import incompleteIcon from '@/assets/images/midterm/未完成.png'
export interface StepItem {
id: string
@@ -111,6 +148,8 @@ interface Props {
steps: StepItem[]
/** 当前步骤索引 */
currentStepIndex?: number
/** 步骤完成状态 */
completedSteps?: boolean[]
/** 是否显示关闭按钮 */
showClose?: boolean
}
@@ -122,7 +161,7 @@ interface Emits {
const props = withDefaults(defineProps<Props>(), {
currentStepIndex: 0,
showClose: true
showClose: true,
})
const emit = defineEmits<Emits>()
@@ -131,10 +170,15 @@ const emit = defineEmits<Emits>()
const pageSize = 5
const currentPage = ref(0)
const totalPages = computed(() => Math.max(1, Math.ceil((props.steps?.length ?? 0) / pageSize)))
const totalPages = computed(() =>
Math.max(1, Math.ceil((props.steps?.length ?? 0) / pageSize)),
)
const currentPageStartIndex = computed(() => currentPage.value * pageSize)
const pagedSteps = computed(() =>
props.steps.slice(currentPageStartIndex.value, currentPageStartIndex.value + pageSize)
props.steps.slice(
currentPageStartIndex.value,
currentPageStartIndex.value + pageSize,
),
)
const canPrevPage = computed(() => currentPage.value > 0)
@@ -164,14 +208,17 @@ const handleClose = () => {
// 当外部当前步骤改变时,自动定位到对应页
watch(
() => props.currentStepIndex,
newVal => {
(newVal) => {
const idx = newVal ?? 0
const targetPage = Math.floor(idx / pageSize)
if (targetPage !== currentPage.value) {
currentPage.value = Math.min(Math.max(targetPage, 0), totalPages.value - 1)
currentPage.value = Math.min(
Math.max(targetPage, 0),
totalPages.value - 1,
)
}
},
{ immediate: true }
{ immediate: true },
)
</script>
+37 -3
View File
@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { computed, onBeforeUnmount, ref } from 'vue'
import { ImgButton } from '@/components/Button'
import { Overlay } from '@/components/Overlay'
import { StarRating } from '@/components/Rating'
@@ -23,11 +23,37 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<{
(e: 'close'): void
(e: 'update:items', items: PracticeRatingItem[]): void
(e: 'save', items: PracticeRatingItem[]): void
}>()
const hasTips = computed<boolean>(() =>
Boolean(props.topTip || props.bottomTip),
)
const showSuccess = ref<boolean>(false)
let successTimer: number | undefined
const handleScoreChange = (index: number, score: number): void => {
const nextItems = props.items.map((item, itemIndex) =>
itemIndex === index ? { ...item, score } : item,
)
emit('update:items', nextItems)
}
const handleSave = (): void => {
emit('save', props.items)
showSuccess.value = true
window.clearTimeout(successTimer)
successTimer = window.setTimeout(() => {
showSuccess.value = false
emit('close')
}, 1500)
}
onBeforeUnmount(() => {
window.clearTimeout(successTimer)
})
</script>
<template>
@@ -36,7 +62,7 @@ const hasTips = computed<boolean>(() =>
:clickable="false"
>
<div
class="w-[1795px] h-[987px] bg-no-repeat bg-cover p-10 pt-[100px]"
class="relative w-[1795px] h-[987px] bg-no-repeat bg-cover p-10 pt-[100px]"
:style="{ backgroundImage: getBackgroundImage(backgroundImage) }"
>
<ImgButton
@@ -45,6 +71,13 @@ const hasTips = computed<boolean>(() =>
@click="emit('close')"
/>
<div
v-if="showSuccess"
class="absolute left-1/2 top-1/2 z-[100] -translate-x-1/2 -translate-y-1/2 rounded-2xl bg-black/70 px-16 py-8 text-5xl font-bold text-white"
>
提交成功
</div>
<div class="relative h-full overflow-y-scroll px-8">
<div class="pl-10 space-y-10 mt-10">
<p
@@ -71,6 +104,7 @@ const hasTips = computed<boolean>(() =>
class="absolute -bottom-5 right-10"
:score="item.score"
size="lg"
@change="handleScoreChange(index, $event)"
/>
</div>
<p
@@ -85,7 +119,7 @@ const hasTips = computed<boolean>(() =>
>
<ImgButton
:image="saveButtonImage"
@click="emit('close')"
@click="handleSave"
/>
</div>
</div>
+22 -6
View File
@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import { getStaticImage } from '@/utils'
interface Props {
@@ -16,7 +16,7 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {
maxStars: 5,
size: 'md',
readonly: false
readonly: false,
})
const emit = defineEmits<{
@@ -34,11 +34,13 @@ const hoverScore = ref<number>(0)
const sizeMap = {
sm: 'w-10 h-10',
md: 'w-14 h-14',
lg: 'w-18 h-18'
lg: 'w-18 h-18',
}
/** 显示的评分(优先显示悬停评分,否则显示当前评分) */
const displayScore = computed<number>(() => hoverScore.value || currentScore.value)
const displayScore = computed<number>(
() => hoverScore.value || currentScore.value,
)
/** 处理星星点击 */
const handleStarClick = (star: number): void => {
@@ -57,6 +59,13 @@ const handleMouseEnter = (star: number): void => {
const handleMouseLeave = (): void => {
hoverScore.value = 0
}
watch(
() => props.score,
(score) => {
currentScore.value = score
},
)
</script>
<template>
@@ -68,9 +77,16 @@ const handleMouseLeave = (): void => {
<img
v-for="star in props.maxStars"
:key="star"
:class="[sizeMap[props.size], !props.readonly && 'transition-transform hover:scale-110']"
:class="[
sizeMap[props.size],
!props.readonly && 'transition-transform hover:scale-110',
]"
:src="
getStaticImage(star <= displayScore ? 'vlog/practice/星星-亮' : 'vlog/practice/星星-灰')
getStaticImage(
star <= displayScore
? 'vlog/practice/星星-亮'
: 'vlog/practice/星星-灰',
)
"
alt=""
@click="handleStarClick(star)"
@@ -0,0 +1 @@
export { default } from './index.vue'
@@ -0,0 +1,80 @@
<script lang="ts" setup>
import { Overlay } from '@/components/Overlay'
interface Props {
visible: boolean
message?: string
continueText?: string
leaveText?: string
}
withDefaults(defineProps<Props>(), {
message: '当前页面还有学习资源没有学习完成,建议继续完成后再离开。',
continueText: '继续学习',
leaveText: '确认离开',
})
const emit = defineEmits<{
(e: 'continue'): void
(e: 'leave'): void
}>()
</script>
<template>
<Overlay
:visible="visible"
:clickable="false"
background="rgba(80, 220, 255, 0.18)"
animation="zoom"
:z-index="3000"
>
<div
class="relative w-[860px] overflow-hidden rounded-[42px] border-[5px] border-white/80 bg-gradient-to-br from-[#e8fbff]/95 via-[#d8ddff]/95 to-[#ffd8f3]/95 px-16 py-14 text-center shadow-[0_24px_70px_rgba(48,122,255,0.32)]"
>
<div
class="pointer-events-none absolute -left-16 -top-20 h-56 w-56 rounded-full bg-[#5ff3ff]/55 blur-3xl"
/>
<div
class="pointer-events-none absolute right-[-70px] top-[-60px] h-60 w-60 rounded-full bg-[#ff65d8]/45 blur-3xl"
/>
<div
class="pointer-events-none absolute bottom-[-90px] left-1/2 h-64 w-64 -translate-x-1/2 rounded-full bg-[#ffe96b]/45 blur-3xl"
/>
<div
class="pointer-events-none absolute inset-x-10 top-5 h-[3px] rounded-full bg-gradient-to-r from-transparent via-white to-transparent"
/>
<div
class="relative mx-auto mb-7 flex h-24 w-24 items-center justify-center rounded-[30px] border-[4px] border-white bg-gradient-to-br from-[#36eaff] to-[#7b7cff] shadow-[0_12px_28px_rgba(54,234,255,0.38)] rotate-[-6deg]"
>
<span
class="text-[56px] font-black leading-none text-white drop-shadow-[0_4px_0_rgba(34,80,180,0.45)]"
>
!
</span>
</div>
<p
class="relative mx-auto mt-7 max-w-[650px] text-[34px] font-black leading-[1.65] text-[#294579]"
>
{{ message }}
</p>
<div class="relative mt-12 flex items-center justify-center gap-9">
<button
class="h-[76px] min-w-[220px] rounded-full border-[3px] border-[#78bfff] bg-white/70 px-10 text-[28px] font-black text-[#3470c8] shadow-[0_10px_0_rgba(70,139,220,0.22)] transition-all duration-200 hover:-translate-y-1 hover:bg-white active:translate-y-1 active:shadow-[0_5px_0_rgba(70,139,220,0.22)]"
@click="emit('leave')"
>
{{ leaveText }}
</button>
<button
class="h-[76px] min-w-[220px] rounded-full border-[3px] border-white bg-gradient-to-r from-[#ff82d8] via-[#ff9f4a] to-[#ffd447] px-10 text-[28px] font-black text-white shadow-[0_10px_0_rgba(236,111,40,0.28),0_16px_35px_rgba(255,130,216,0.32)] transition-all duration-200 hover:-translate-y-1 active:translate-y-1 active:shadow-[0_5px_0_rgba(236,111,40,0.28)]"
@click="emit('continue')"
>
{{ continueText }}
</button>
</div>
</div>
</Overlay>
</template>
+1
View File
@@ -20,6 +20,7 @@ export * from './ImagePreview'
// 弹窗组件
export * from './Modal'
export * from './UnfinishedLearningPrompt'
// 遮罩层组件
export * from './Overlay'