Files
super_video/src/views/film/practice/index.vue
T
2026-06-23 10:15:57 +08:00

519 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 { GuideSteps, type GuideStep } from '@/components/Guide'
import { useFilmStore } from '@/stores/modules/film'
const router = useRouter()
const filmStore = useFilmStore()
const TOTAL_PAGES = 3
const PAGE_HEIGHT = 1080
const SCROLL_DURATION = 1000
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
})
const visible = ref<boolean>(false)
const guideKeyPrefix = 'film-practice-page'
const pageGuideSteps: Record<number, GuideStep[]> = {
0: [
{
id: 'film-practice-page-0-2',
content: '这里可以查看任务描述。',
highlightSelector: '.desc-img',
positionOffsetY: '87%',
audioSrc: '/audio/py_6_002.MP3',
},
{
id: 'film-practice-page-0-3',
content: '这里可以查看任务具体要求。',
highlightSelector: '.task-btn',
positionOffsetY: '50%',
audioSrc: '/audio/py_6_003.MP3',
},
{
id: 'film-practice-page-0-4',
content: '点击下滑箭头,可查看更多学习与实践内容。',
highlightSelector: '.down-scroll-hint',
audioSrc: '/audio/py_6_005.MP3',
},
],
1: [
{
id: 'film-practice-page-1-1',
content: '点击【我的创作笔记】,记录自己的创作思路与作品说明。',
highlightSelector: '.my-notes-btn',
audioSrc: '/audio/py_6_004.MP3',
},
],
2: [
{
id: 'film-practice-page-2-1',
content: '点击【查看】按钮,欣赏学长学姐们的优秀实践作品。',
highlightSelector: '.view-btn',
positionOffsetY: '50%',
audioSrc: '/audio/py_6_006.MP3',
},
{
id: 'film-practice-page-2-3',
content: '点击向上按钮,可快速返回实战操作台首页。',
highlightSelector: '.back-to-top-btn',
positionOffsetY: '50%',
audioSrc: '/audio/py_6_009.MP3',
},
],
}
const currentGuideKey = computed(() => `${guideKeyPrefix}-${currentPage.value}`)
const currentGuideSteps = computed(
() => pageGuideSteps[currentPage.value] ?? [],
)
const showPageGuide = (): void => {
if (filmStore.guideShown[currentGuideKey.value]) return
visible.value = true
}
const handlePageGuideComplete = (): void => {
visible.value = false
filmStore.markGuideShown(currentGuideKey.value)
}
/** 他人实战作品列表 */
interface WorkItem {
id: number
image: string
title: string
stars: number
path: string
watchKey: string
description: string
}
const worksList = ref<WorkItem[]>([
{
id: 1,
image: getStaticImage('film/practice/学生案例'),
title: '学生案例',
stars: 4,
path: '/video/film/practice/film_anli1.mp4',
watchKey: 'film-practice-case-1',
description: '27084 施颖雯 杨可馨 胡悦莹 陆佳慧 雷鑫丽',
},
])
const getCachedWorkScore = (work: WorkItem): number => {
const scores = filmStore.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
showPageGuide()
}
}
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 = filmStore.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))
filmStore.setPracticeScrollPage(targetPage)
}
onMounted(() => {
restoreScrollPage()
showPageGuide()
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="task-btn absolute left-40 bottom-14 z-10"
image="vlog/practice/查看任务要求"
@click="scrollToNextPage"
/>
<!-- img -->
<img
class="desc-img absolute left-0 top-0 z-10"
draggable="false"
:src="getStaticImage('film/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="my-notes-btn absolute right-20 bottom-0 z-99"
image="vlog/practice/我的创作笔记btn"
path="/film/practice/my-notes"
/>
<div
class="task-panel w-[757px] h-full ml-10 overflow-y-auto"
@wheel.stop
>
<img
:src="getStaticImage('film/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, index) 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
:class="index === 0 ? 'view-btn' : ''"
image="vlog/practice/查看"
:path="`/film/practice/video/${encodeURIComponent(work.path)}?watchKey=${work.watchKey}`"
/>
</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="back-to-top-btn absolute bottom-10 left-1/2 -translate-x-1/2 cursor-pointer transition-all duration-300 hover:scale-105"
:src="BackToTopIcon"
@click="scrollToTop"
/>
<!-- 跨页人物动画 -->
<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>
<GuideSteps
v-if="visible"
:steps="currentGuideSteps"
@complete="handlePageGuideComplete"
/>
</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>