forked from zhongyidian/super_video
完善新手导航和三大创作模块的视频完成缓存、未完成提示与学习进度计算,确保进度按实际学习资源完成情况更新。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
458 lines
13 KiB
Vue
458 lines
13 KiB
Vue
<script lang="ts" setup>
|
||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
|
||
import { ImgButton } from '@/components/Button'
|
||
import { StarRating } from '@/components/Rating'
|
||
import { useRouter } from 'vue-router'
|
||
import BackToTopIcon from '@/assets/images/components/置顶.png'
|
||
import { useInterviewStore } from '@/stores/modules/interview'
|
||
|
||
const router = useRouter()
|
||
|
||
const TOTAL_PAGES = 3
|
||
const PAGE_HEIGHT = 1080
|
||
const SCROLL_DURATION = 1000
|
||
|
||
const interviewStore = useInterviewStore()
|
||
|
||
const currentPage = ref<number>(0)
|
||
const worksScrollIndex = ref<number>(0)
|
||
const animatingToPage = ref<number>(0)
|
||
const containerRef = ref<HTMLElement | null>(null)
|
||
const isAnimating = ref<boolean>(false)
|
||
|
||
/** 是否为最后一页 */
|
||
const isLastPage = computed<boolean>(() => currentPage.value >= TOTAL_PAGES - 1)
|
||
|
||
/** 是否正在从第二页回到第一页 */
|
||
const isScrollingBackToFirst = computed<boolean>(() => {
|
||
return (
|
||
isAnimating.value && currentPage.value === 1 && animatingToPage.value === 0
|
||
)
|
||
})
|
||
|
||
/** 第一页坐着的人物是否显示 */
|
||
const showSittingPerson = computed<boolean>(() => {
|
||
// 当前在第一页,且不是正在回滚中
|
||
return currentPage.value === 0 && !isScrollingBackToFirst.value
|
||
})
|
||
|
||
/** 他人实战作品列表 */
|
||
interface WorkItem {
|
||
id: number
|
||
image: string
|
||
title: string
|
||
stars: number
|
||
path: string
|
||
description: string
|
||
}
|
||
|
||
const worksList = ref<WorkItem[]>([
|
||
{
|
||
id: 1,
|
||
image: getStaticImage('interview/practice/访谈-匠心育人润物无声'),
|
||
title: '匠心育人润物无声',
|
||
stars: 4,
|
||
path: 'video/interview/practice/fangtan_cace1_batch.mp4',
|
||
description:
|
||
'作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介',
|
||
},
|
||
// {
|
||
// id: 2,
|
||
// image: 'https://picsum.photos/400/300?random=2',
|
||
// title: '作品名称',
|
||
// stars: 5,
|
||
// path: '/video/practice/vlog_anli2.mp4',
|
||
// description: '作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介'
|
||
// }
|
||
])
|
||
|
||
const getCachedWorkScore = (work: WorkItem): number => {
|
||
const scores = interviewStore.practiceWorkRatings[work.path]
|
||
const values = scores ? Object.values(scores) : []
|
||
|
||
if (values.length === 0) return work.stars
|
||
|
||
return Math.round(
|
||
values.reduce((total, score) => total + score, 0) / values.length,
|
||
)
|
||
}
|
||
|
||
/** 左滑动作品列表 */
|
||
const scrollWorksLeft = (): void => {
|
||
if (worksScrollIndex.value > 0) {
|
||
worksScrollIndex.value--
|
||
}
|
||
}
|
||
|
||
/** 右滑动作品列表 */
|
||
const scrollWorksRight = (): void => {
|
||
if (worksScrollIndex.value < worksList.value.length - 3) {
|
||
worksScrollIndex.value++
|
||
}
|
||
}
|
||
|
||
const handleClose = (): void => {
|
||
router.back()
|
||
}
|
||
|
||
/** 自定义平滑滚动动画 */
|
||
const smoothScrollTo = (toPage: number): void => {
|
||
const container = containerRef.value
|
||
if (!container || isAnimating.value) return
|
||
|
||
isAnimating.value = true
|
||
animatingToPage.value = toPage
|
||
const startTop = container.scrollTop
|
||
const targetTop = toPage * PAGE_HEIGHT
|
||
const distance = targetTop - startTop
|
||
const startTime = performance.now()
|
||
|
||
/** easeInOutQuart 缓动函数 - 更平滑 */
|
||
const easeInOutQuart = (t: number): number => {
|
||
return t < 0.5 ? 8 * t * t * t * t : 1 - Math.pow(-2 * t + 2, 4) / 2
|
||
}
|
||
|
||
const animateScroll = (currentTime: number): void => {
|
||
const elapsed = currentTime - startTime
|
||
const progress = Math.min(elapsed / SCROLL_DURATION, 1)
|
||
const easeProgress = easeInOutQuart(progress)
|
||
|
||
container.scrollTop = startTop + distance * easeProgress
|
||
|
||
if (progress < 1) {
|
||
requestAnimationFrame(animateScroll)
|
||
} else {
|
||
isAnimating.value = false
|
||
currentPage.value = toPage
|
||
}
|
||
}
|
||
|
||
requestAnimationFrame(animateScroll)
|
||
}
|
||
|
||
/** 滚动到指定页 */
|
||
const scrollToPage = (page: number): void => {
|
||
const targetPage = Math.max(0, Math.min(page, TOTAL_PAGES - 1))
|
||
if (targetPage !== currentPage.value && !isAnimating.value) {
|
||
smoothScrollTo(targetPage)
|
||
}
|
||
}
|
||
|
||
/** 滚动到下一页 */
|
||
const scrollToNextPage = (): void => {
|
||
scrollToPage(currentPage.value + 1)
|
||
}
|
||
|
||
/** 滚动到上一页 */
|
||
const scrollToPrevPage = (): void => {
|
||
scrollToPage(currentPage.value - 1)
|
||
}
|
||
|
||
/** 回到顶部 */
|
||
const scrollToTop = (): void => {
|
||
scrollToPage(0)
|
||
}
|
||
|
||
/** 处理鼠标滚轮事件 */
|
||
const handleWheel = (event: WheelEvent): void => {
|
||
event.preventDefault()
|
||
if (isAnimating.value) return
|
||
|
||
if (event.deltaY > 0) {
|
||
scrollToNextPage()
|
||
} else if (event.deltaY < 0) {
|
||
scrollToPrevPage()
|
||
}
|
||
}
|
||
|
||
const restoreScrollPage = (): void => {
|
||
const savedPage = interviewStore.practiceScrollPage
|
||
const targetPage = Math.max(0, Math.min(savedPage, TOTAL_PAGES - 1))
|
||
|
||
currentPage.value = targetPage
|
||
animatingToPage.value = targetPage
|
||
requestAnimationFrame(() => {
|
||
if (containerRef.value) {
|
||
containerRef.value.scrollTop = targetPage * PAGE_HEIGHT
|
||
}
|
||
})
|
||
}
|
||
|
||
const saveScrollPage = (): void => {
|
||
const page = containerRef.value
|
||
? Math.round(containerRef.value.scrollTop / PAGE_HEIGHT)
|
||
: currentPage.value
|
||
const targetPage = Math.max(0, Math.min(page, TOTAL_PAGES - 1))
|
||
interviewStore.setPracticeScrollPage(targetPage)
|
||
}
|
||
|
||
onMounted(() => {
|
||
restoreScrollPage()
|
||
containerRef.value?.addEventListener('wheel', handleWheel, { passive: false })
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
saveScrollPage()
|
||
containerRef.value?.removeEventListener('wheel', handleWheel)
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div
|
||
ref="containerRef"
|
||
class="w-full h-full overflow-auto"
|
||
>
|
||
<div class="relative min-h-full overflow-hidden">
|
||
<!-- 关闭 -->
|
||
<ImgButton
|
||
class="absolute right-0 top-0 z-50"
|
||
:image="IMAGE_PATHS.COMPONENTS.BACK"
|
||
@click="handleClose"
|
||
/>
|
||
<!-- 实战操作台 -->
|
||
<div class="w-full h-[1080px] bg-[#F5EFE6] relative">
|
||
<ImgButton
|
||
class="absolute left-40 bottom-12 z-10"
|
||
image="vlog/practice/查看任务要求"
|
||
@click="scrollToNextPage"
|
||
/>
|
||
<!-- img -->
|
||
<img
|
||
class="absolute left-0 top-0 z-10"
|
||
draggable="false"
|
||
:src="getStaticImage('interview/practice/访谈')"
|
||
alt=""
|
||
/>
|
||
<img
|
||
class="absolute right-0"
|
||
draggable="false"
|
||
:src="getStaticImage('vlog/practice/背景1')"
|
||
alt=""
|
||
/>
|
||
</div>
|
||
<!-- 任务要求 -->
|
||
<div
|
||
class="w-full h-[1080px] bg-cover bg-no-repeat relative overflow-hidden"
|
||
:style="{ backgroundImage: getBackgroundImage('vlog/practice/背景2') }"
|
||
>
|
||
<ImgButton
|
||
class="absolute right-20 bottom-0 z-99"
|
||
image="vlog/practice/我的创作笔记btn"
|
||
path="/interview/practice/my-notes"
|
||
/>
|
||
|
||
<div
|
||
class="task-panel w-[757px] h-full ml-10 overflow-y-auto"
|
||
@wheel.stop
|
||
>
|
||
<img
|
||
:src="getStaticImage('interview/practice/任务要求')"
|
||
alt=""
|
||
/>
|
||
</div>
|
||
|
||
<img
|
||
class="absolute right-0 bottom-0 pointer-events-none"
|
||
:src="getStaticImage('vlog/practice/摄像机')"
|
||
alt=""
|
||
/>
|
||
<!-- 站着的人物 - 第二页固定在此(不在回滚动画中) -->
|
||
<Transition name="fade">
|
||
<img
|
||
v-show="currentPage === 1 && !isScrollingBackToFirst"
|
||
class="absolute right-8 bottom-0 max-h-[90%] pointer-events-none"
|
||
draggable="false"
|
||
:src="getStaticImage('vlog/practice/摄像机-人')"
|
||
alt=""
|
||
/>
|
||
</Transition>
|
||
</div>
|
||
<!-- 他人实战作品 -->
|
||
<div
|
||
class="w-full h-[1080px] bg-cover bg-no-repeat pt-44 px-16"
|
||
:style="{
|
||
backgroundImage: getBackgroundImage('vlog/practice/他人实战作品'),
|
||
}"
|
||
>
|
||
<!-- 作品列表 -->
|
||
<div class="w-full overflow-hidden">
|
||
<div
|
||
class="flex gap-6 transition-transform duration-500"
|
||
:style="{ transform: `translateX(-${worksScrollIndex * 509}px)` }"
|
||
>
|
||
<div
|
||
v-for="work in worksList"
|
||
:key="work.id"
|
||
class="w-[503px] h-[722px] flex-shrink-0 rounded-[40px] overflow-hidden bg-[#FFF8F0] py-8 px-6"
|
||
>
|
||
<!-- 作品图片 -->
|
||
<div class="w-full h-[280px] rounded-2xl overflow-hidden">
|
||
<img
|
||
class="w-full h-full object-cover"
|
||
:src="work.image"
|
||
:alt="work.title"
|
||
/>
|
||
</div>
|
||
<!-- 作品信息 -->
|
||
<div class="flex flex-col items-center pt-8 space-y-4">
|
||
<h3 class="text-5xl font-bold text-gray-800 mb-2">
|
||
{{ work.title }}
|
||
</h3>
|
||
<!-- 星星评分 -->
|
||
<StarRating
|
||
:score="getCachedWorkScore(work)"
|
||
size="lg"
|
||
readonly
|
||
/>
|
||
<!-- 简介 -->
|
||
<p
|
||
class="h-[110px] text-2xl text-gray-600 mb-3 line-clamp-3 leading-relaxed"
|
||
>
|
||
{{ work.description }}
|
||
</p>
|
||
<!-- 查看按钮 -->
|
||
<ImgButton
|
||
image="vlog/practice/查看"
|
||
:path="`/interview/practice/video/${encodeURIComponent(work.path)}`"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 指示按钮,暂时不要
|
||
<div class="absolute bottom-5 left-1/2 -translate-x-1/2 flex gap-16">
|
||
<img
|
||
class="cursor-pointer hover:scale-110 transition-transform"
|
||
:class="{ 'opacity-50 cursor-not-allowed': worksScrollIndex === 0 }"
|
||
:src="getStaticImage('vlog/practice/滑动左')"
|
||
alt="向左"
|
||
@click="scrollWorksLeft"
|
||
/>
|
||
<img
|
||
class="cursor-pointer hover:scale-110 transition-transform rotate-180"
|
||
:class="{
|
||
'opacity-50 cursor-not-allowed':
|
||
worksScrollIndex >= worksList.length - 3,
|
||
}"
|
||
:src="getStaticImage('vlog/practice/滑动左')"
|
||
alt="向右"
|
||
@click="scrollWorksRight"
|
||
/>
|
||
</div>
|
||
-->
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 下滑按钮 - 最后一页隐藏 -->
|
||
<ImgButton
|
||
v-show="!isLastPage"
|
||
class="absolute bottom-0 left-1/2 down-scroll-hint"
|
||
:image="'vlog/practice/下滑'"
|
||
@click="scrollToNextPage"
|
||
/>
|
||
|
||
<!-- 回到顶部按钮 - 最后一页显示 -->
|
||
<img
|
||
v-show="isLastPage"
|
||
class="absolute bottom-10 left-1/2 -translate-x-1/2 cursor-pointer transition-all duration-300 hover:scale-105"
|
||
:src="BackToTopIcon"
|
||
@click="scrollToTop"
|
||
/>
|
||
|
||
<!-- 老师点评按钮 - 最后一页显示 -->
|
||
<ImgButton
|
||
v-show="isLastPage"
|
||
class="absolute right-2 bottom-10 z-30"
|
||
image="components/教师点评按钮"
|
||
path="/interview/practice/teacher-comment"
|
||
/>
|
||
|
||
<!-- 跨页人物动画 -->
|
||
<div
|
||
v-show="showSittingPerson || isScrollingBackToFirst"
|
||
class="character-container"
|
||
>
|
||
<!-- 坐着的人物 - 第一页 -->
|
||
<Transition name="fade">
|
||
<img
|
||
v-show="showSittingPerson"
|
||
class="character-img right-20 bottom-10"
|
||
draggable="false"
|
||
:src="getStaticImage('vlog/practice/背景1-人')"
|
||
alt=""
|
||
/>
|
||
</Transition>
|
||
<!-- 站着的人物 - 回滚动画中 -->
|
||
<Transition name="fade">
|
||
<img
|
||
v-show="isScrollingBackToFirst"
|
||
class="character-img right-8 bottom-0"
|
||
draggable="false"
|
||
:src="getStaticImage('vlog/practice/摄像机-人')"
|
||
alt=""
|
||
/>
|
||
</Transition>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* 隐藏滚动条但保持可滚动 - 兼容主流浏览器 */
|
||
.task-panel {
|
||
scrollbar-width: none;
|
||
/* Firefox */
|
||
-ms-overflow-style: none;
|
||
/* IE/Edge */
|
||
}
|
||
|
||
.task-panel::-webkit-scrollbar {
|
||
display: none;
|
||
/* Chrome/Safari/Opera */
|
||
}
|
||
|
||
.down-scroll-hint {
|
||
transform: translateX(-50%);
|
||
animation: down-scroll-hint 1.4s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes down-scroll-hint {
|
||
0%,
|
||
100% {
|
||
transform: translate(-50%, 0);
|
||
}
|
||
|
||
50% {
|
||
transform: translate(-50%, 12px);
|
||
}
|
||
}
|
||
|
||
/* 第一页人物动画容器 */
|
||
.character-container {
|
||
position: fixed;
|
||
right: 0;
|
||
top: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
pointer-events: none;
|
||
z-index: 20;
|
||
}
|
||
|
||
.character-img {
|
||
position: absolute;
|
||
max-height: 90%;
|
||
}
|
||
|
||
/* 淡入淡出动画 */
|
||
.fade-enter-active,
|
||
.fade-leave-active {
|
||
transition: opacity 1s ease;
|
||
}
|
||
|
||
.fade-enter-from,
|
||
.fade-leave-to {
|
||
opacity: 0;
|
||
}
|
||
</style>
|