forked from zhongyidian/super_video
feat: 完善视频学习进度缓存
完善新手导航和三大创作模块的视频完成缓存、未完成提示与学习进度计算,确保进度按实际学习资源完成情况更新。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
1f8723aabd
commit
6f289959be
63 files changed
+3139
-1126
No files matched your search
@@ -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>
|
||||
@@ -57,6 +57,7 @@
|
||||
<!-- 支持字符串形式的 content -->
|
||||
<div
|
||||
v-else
|
||||
class="whitespace-pre-line"
|
||||
v-html="currentStepContent"
|
||||
></div>
|
||||
</GuideStepsItem>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -20,6 +20,7 @@ export * from './ImagePreview'
|
||||
|
||||
// 弹窗组件
|
||||
export * from './Modal'
|
||||
export * from './UnfinishedLearningPrompt'
|
||||
|
||||
// 遮罩层组件
|
||||
export * from './Overlay'
|
||||
|
||||
Reference in new issue
Block a user