feat: 优化移动端学习与证书体验

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zhongyidianandClaude Opus 5 committed 2026-09-21 10:30:13 +08:00
1 parent dd233e102b
commit 680e8b97d7
55 files changed
+513 -230

No files matched your search

@@ -111,6 +111,13 @@ const markCurrentLearningVideoWatched = () => {
)
}
const currentVideoWatched = computed(
() =>
basicElementsStore.learningVideosWatched[
getLearningVideoKey(currentVideoIndex.value)
] === true,
)
//按顺序播放
const currentVideo = computed(
() => navigationSteps.value[currentVideoIndex.value].videoUrl,
@@ -334,6 +341,7 @@ onBeforeRouteLeave((to) => {
:controls="true"
:hideNativeControls="true"
:anchors="[]"
:seekable="currentVideoWatched"
@play="handlePlay"
@ended="onEnded"
>
@@ -103,6 +103,13 @@ const markCurrentLearningVideoWatched = () => {
)
}
const currentVideoWatched = computed(
() =>
basicElementsStore.learningVideosWatched[
getLearningVideoKey(currentVideoIndex.value)
] === true,
)
//按顺序播放
const currentVideo = computed(
() => navigationSteps.value[currentVideoIndex.value].videoUrl,
@@ -205,6 +212,7 @@ onBeforeRouteLeave((to) => {
:controls="true"
:hideNativeControls="true"
:anchors="[]"
:seekable="currentVideoWatched"
@play="handlePlay"
@ended="onEnded"
>
@@ -74,6 +74,13 @@ const hasUnfinishedLearningVideos = computed(() =>
completedNavigationSteps.value.some((completed) => !completed),
)
const currentVideoWatched = computed(
() =>
basicElementsStore.learningVideosWatched[
getLearningVideoKey(currentVideoIndex.value)
] === true,
)
const markCurrentLearningVideoWatched = () => {
basicElementsStore.markLearningVideoWatched(
getLearningVideoKey(currentVideoIndex.value),
@@ -184,6 +191,7 @@ onMounted(() => {})
:controls="true"
:hideNativeControls="true"
:anchors="[]"
:seekable="currentVideoWatched"
@play="handlePlay"
@ended="onEnded"
>
+8
View File
@@ -106,6 +106,13 @@ const markCurrentLearningVideoWatched = () => {
)
}
const currentVideoWatched = computed(
() =>
basicElementsStore.learningVideosWatched[
getLearningVideoKey(currentVideoIndex.value)
] === true,
)
//按顺序播放
const currentVideo = computed(
() => navigationSteps.value[currentVideoIndex.value].videoUrl,
@@ -266,6 +273,7 @@ onBeforeRouteLeave((to) => {
:controls="true"
:hideNativeControls="true"
:anchors="[]"
:seekable="currentVideoWatched"
@play="handlePlay"
@ended="onEnded"
>
@@ -148,6 +148,13 @@ const markCurrentLearningVideoWatched = () => {
)
}
const currentVideoWatched = computed(
() =>
basicElementsStore.learningVideosWatched[
getLearningVideoKey(currentVideoIndex.value)
] === true,
)
//按顺序播放
const currentVideo = computed(() => rawSteps[currentVideoIndex.value].videoUrl)
@@ -248,6 +255,7 @@ onBeforeRouteLeave((to) => {
:controls="true"
:hideNativeControls="true"
:anchors="[]"
:seekable="currentVideoWatched"
@play="handlePlay"
@ended="onEnded"
>
+1
View File
@@ -60,6 +60,7 @@ const back = () => {
controls
:muted="false"
:volume="1"
:seekable="filmStore.viewed['FilmColor'] === true"
@ended="handleVideoEnded"
/>
</div>
@@ -39,6 +39,7 @@ const handleVideoEnded = () => {
:controls="true"
:hideNativeControls="true"
:autoplay="true"
:seekable="film.editingVideosWatched['association'] === true"
:show-center-play-icon="true"
@ended="handleVideoEnded"
/>
@@ -35,6 +35,7 @@ const handleVideoEnded = () => {
:controls="true"
:hideNativeControls="true"
:autoplay="true"
:seekable="film.editingVideosWatched['continuity'] === true"
:show-center-play-icon="true"
@ended="handleVideoEnded"
></Video>
@@ -37,6 +37,7 @@ const handleVideoEnded = () => {
:controls="true"
:hideNativeControls="true"
:autoplay="true"
:seekable="film.editingVideosWatched['cut'] === true"
:show-center-play-icon="true"
@ended="handleVideoEnded"
/>
@@ -213,7 +213,6 @@ const handleEditingCompleteGuideDone = () => {
:steps="editingCompleteSteps"
position-offset-y="85%"
@complete="handleEditingCompleteGuideDone"
@skip="handleEditingCompleteGuideDone"
/>
</div>
</template>
@@ -37,6 +37,7 @@ const handleVideoEnded = () => {
:controls="true"
:hideNativeControls="true"
:autoplay="true"
:seekable="film.editingVideosWatched['rhythm'] === true"
:show-center-play-icon="true"
@ended="handleVideoEnded"
/>
@@ -43,6 +43,7 @@ const handleVideoEnded = () => {
:controls="true"
:hideNativeControls="true"
:autoplay="true"
:seekable="film.editingVideosWatched['start'] === true"
:show-center-play-icon="true"
@ended="handleVideoEnded"
></Video>
@@ -50,6 +50,7 @@ const handleVideoEnded = () => {
:volume="1"
:controls="true"
:hideNativeControls="true"
:seekable="filmStore.viewed['FilmExportPublish'] === true"
@ended="handleVideoEnded"
/>
@@ -64,7 +65,6 @@ const handleVideoEnded = () => {
v-if="showGuide"
position-offset-y="85%"
@complete="handleComplete"
@skip="handleComplete"
>
<GuideStepsItem :audio-src="getPublicPath('audio/py_4_019.MP3')">
现在一起来看看导出后的影片效果吧!
-2
View File
@@ -384,7 +384,6 @@ onMounted(() => {
:steps="returnSteps"
position-offset-y="85%"
@complete="handleReturnGuideComplete"
@skip="handleReturnGuideComplete"
/>
<!-- 阶段菜单一次性引导(中期拍摄 / 后期剪辑) -->
@@ -393,7 +392,6 @@ onMounted(() => {
:steps="stageGuideSteps"
position-offset-y="85%"
@complete="handleStageGuideComplete"
@skip="handleStageGuideComplete"
/>
<!-- <GuideSteps
@@ -40,6 +40,7 @@ const handleVideoEnded = () => {
:controls="true"
:hideNativeControls="true"
:autoplay="true"
:seekable="filmStore.viewed['FilmOnsiteRandom'] === true"
@ended="handleVideoEnded"
/>
</div>
@@ -75,6 +75,7 @@ const handleQuestionsComplete = () => {
:controls="true"
:hideNativeControls="true"
:autoplay="true"
:seekable="filmStore.viewed['FilmOnsiteSlider'] === true"
@ended="handleVideoEnded"
>
<template #overlay>
@@ -51,6 +51,7 @@ const handleVideoEnded = () => {
:controls="true"
:hideNativeControls="true"
:autoplay="true"
:seekable="filmStore.viewed['FilmOnsiteSlowShutterNight'] === true"
@ended="handleVideoEnded"
/>
</div>
+45 -2
View File
@@ -227,6 +227,41 @@ const handleWheel = (event: WheelEvent): void => {
}
}
/** 触摸起点(移动端滑动翻页) */
let touchStartY = 0
let touchStartX = 0
/** 触摸点是否落在可独立滚动的内部面板(如任务要求)中 */
const isInPanelScroll = (target: EventTarget | null): boolean => {
const panel = (target as HTMLElement | null)?.closest('.task-panel')
return !!panel && panel.scrollHeight > panel.clientHeight
}
/** 记录触摸起点 */
const handleTouchStart = (event: TouchEvent): void => {
touchStartY = event.touches[0].clientY
touchStartX = event.touches[0].clientX
}
/** 阻止容器原生滚动,改由整页翻页控制(内部面板除外) */
const handleTouchMove = (event: TouchEvent): void => {
if (!isInPanelScroll(event.target)) event.preventDefault()
}
/** 根据滑动方向翻页 */
const handleTouchEnd = (event: TouchEvent): void => {
if (isAnimating.value || isInPanelScroll(event.target)) return
const deltaY = touchStartY - event.changedTouches[0].clientY
const deltaX = touchStartX - event.changedTouches[0].clientX
// 仅响应以纵向为主、且超过阈值的滑动
if (Math.abs(deltaY) < 40 || Math.abs(deltaX) > Math.abs(deltaY)) return
if (deltaY > 0) {
scrollToNextPage()
} else {
scrollToPrevPage()
}
}
const restoreScrollPage = (): void => {
const savedPage = filmStore.practiceScrollPage
const targetPage = Math.max(0, Math.min(savedPage, TOTAL_PAGES - 1))
@@ -251,12 +286,20 @@ const saveScrollPage = (): void => {
onMounted(() => {
restoreScrollPage()
showPageGuide()
containerRef.value?.addEventListener('wheel', handleWheel, { passive: false })
const container = containerRef.value
container?.addEventListener('wheel', handleWheel, { passive: false })
container?.addEventListener('touchstart', handleTouchStart, { passive: true })
container?.addEventListener('touchmove', handleTouchMove, { passive: false })
container?.addEventListener('touchend', handleTouchEnd, { passive: true })
})
onUnmounted(() => {
saveScrollPage()
containerRef.value?.removeEventListener('wheel', handleWheel)
const container = containerRef.value
container?.removeEventListener('wheel', handleWheel)
container?.removeEventListener('touchstart', handleTouchStart)
container?.removeEventListener('touchmove', handleTouchMove)
container?.removeEventListener('touchend', handleTouchEnd)
})
</script>
+12 -3
View File
@@ -83,10 +83,18 @@ const handleSaveEvaluation = (items: PracticeRatingItem[]): void => {
store.setPracticeWorkRating(workKey.value, toScores(items))
}
const watchKey = computed<string | undefined>(() => {
const val = route.query.watchKey
return typeof val === 'string' ? val : undefined
})
const isWatched = computed(() =>
watchKey.value ? store.practiceVideosWatched[watchKey.value] === true : true,
)
const handleVideoEnded = (): void => {
const watchKey = route.query.watchKey
if (typeof watchKey === 'string' && watchKey) {
filmStore.markPracticeVideoWatched(watchKey)
if (watchKey.value) {
filmStore.markPracticeVideoWatched(watchKey.value)
}
}
@@ -118,6 +126,7 @@ const handleBack = (): void => {
:muted="false"
:controls="true"
:loop="false"
:seekable="isWatched"
@ended="handleVideoEnded"
/>
+8 -1
View File
@@ -116,6 +116,13 @@ const hasUnfinishedLearningVideos = computed(
completedNavigationSteps.value.some((completed) => !completed),
)
const currentVideoWatched = computed(
() =>
film.professionVideosWatched[
professionVideoKeys[currentVideoIndex.value]
] === true,
)
const markCurrentProfessionVideoWatched = () => {
const videoKey = professionVideoKeys[currentVideoIndex.value]
if (!videoKey) return
@@ -379,6 +386,7 @@ onMounted(() => {
:volume="1"
:controls="true"
:hideNativeControls="true"
:seekable="key === 'profession' ? currentVideoWatched : true"
@play="handlePlay"
@ended="onEnded"
>
@@ -439,7 +447,6 @@ onMounted(() => {
position-offset-y="85%"
@step-change="(_: any, index: number) => (guideStepIndex = index)"
@complete="handleProfessionGuideComplete"
@skip="handleProfessionGuideComplete"
>
<GuideStepsItem :audio-src="getPublicPath('audio/py_4_004.MP3')">
从刚才的案例可以看出,电影感画面自带一种独特的艺术魅力——或大气恢弘,扑朔迷离或充满故事感。
-2
View File
@@ -254,7 +254,6 @@ function openImages(urls: string[], startIndex = 0) {
:highlight-radius="20"
@step-change="handleStepChange"
@complete="handleGuideComplete"
@skip="handleGuideComplete"
position-offset-y="85%"
>
<GuideStepsItem :audio-src="getPublicPath('audio/py_4_001.MP3')">
@@ -274,7 +273,6 @@ function openImages(urls: string[], startIndex = 0) {
:highlight-padding="12"
:highlight-radius="20"
@complete="handleReturnGuideComplete"
@skip="handleReturnGuideComplete"
position-offset-y="85%"
/>
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { onMounted } from 'vue'
import { getStaticImage } from '@/utils'
import { ImgButton } from '@/components'
@@ -18,6 +19,13 @@ const emit = defineEmits<{
close: []
}>()
const certificateDetailSrc = getStaticImage('components/medal/证书详情')
onMounted(() => {
const img = new Image()
img.src = certificateDetailSrc
})
const handleClose = () => {
visible.value = false
emit('close')
@@ -25,14 +33,7 @@ const handleClose = () => {
</script>
<template>
<Transition
enter-active-class="transition-all duration-500 ease-out"
enter-from-class="opacity-0 scale-95 blur-[6px]"
enter-to-class="opacity-100 scale-100 blur-none"
leave-active-class="transition-all duration-300 ease-in"
leave-from-class="opacity-100 scale-100 blur-none"
leave-to-class="opacity-0 scale-95 blur-[6px]"
>
<Transition name="certificate-fade">
<div
v-if="visible"
class="absolute inset-0 z-[120] flex items-center justify-center bg-black/45"
@@ -41,12 +42,12 @@ const handleClose = () => {
<div class="certificate-detail relative text-2xl">
<img
draggable="false"
:src="getStaticImage('components/medal/证书详情')"
:src="certificateDetailSrc"
class="certificate-detail-img object-contain"
alt=""
/>
<ImgButton
class="absolute right-2 top-0 z-10"
class="absolute right-0 top-0 z-10"
:image="'components/close'"
@click="handleClose"
/>
@@ -58,22 +59,57 @@ const handleClose = () => {
<!-- 颁发日期 -->
<span class="absolute bottom-[122px] left-[390px]">{{ date }}</span>
<!-- 多少位学员 -->
<span class="absolute bottom-[468px] left-[948px] text-3xl">
<!-- <span class="absolute bottom-[468px] left-[948px] text-3xl">
{{ studentNumber }}
</span>
<span class="absolute bottom-[380px] left-[1070px]">
{{ studentNumber }}
</span>
</span> -->
</div>
</div>
</Transition>
</template>
<style scoped>
.certificate-fade-enter-active {
transition: opacity 220ms ease-out;
}
.certificate-fade-leave-active {
transition: opacity 160ms ease-in;
}
.certificate-fade-enter-from,
.certificate-fade-leave-to {
opacity: 0;
}
.certificate-fade-enter-active .certificate-detail,
.certificate-fade-leave-active .certificate-detail {
transition:
opacity 220ms ease,
transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.certificate-fade-enter-from .certificate-detail {
opacity: 0;
transform: translate3d(0, 36px, 0) scale(0.96);
}
.certificate-fade-enter-to .certificate-detail,
.certificate-fade-leave-from .certificate-detail {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
.certificate-fade-leave-to .certificate-detail {
opacity: 0;
transform: translate3d(0, 24px, 0) scale(0.98);
}
.certificate-detail {
animation: certificate-detail-pop 650ms cubic-bezier(0.18, 0.9, 0.24, 1.12)
both;
filter: drop-shadow(0 0 38px rgba(133, 236, 255, 0.85));
transform: translateZ(0);
will-change: transform, opacity;
}
.certificate-detail::before {
@@ -84,42 +120,14 @@ const handleClose = () => {
border-radius: 48px;
background: radial-gradient(
circle,
rgba(255, 255, 255, 0.55) 0%,
rgba(117, 235, 255, 0.32) 38%,
rgba(255, 255, 255, 0.35) 0%,
rgba(117, 235, 255, 0.2) 38%,
rgba(117, 235, 255, 0) 72%
);
animation: certificate-glow 1600ms ease-in-out infinite;
}
.certificate-detail-img {
width: 100%;
height: 100%;
}
@keyframes certificate-detail-pop {
0% {
opacity: 0;
transform: translateY(28px) scale(0.9);
}
58% {
opacity: 1;
transform: translateY(-8px) scale(1.025);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes certificate-glow {
0%,
100% {
opacity: 0.45;
transform: scale(0.98);
}
50% {
opacity: 0.85;
transform: scale(1.04);
}
}
</style>
@@ -125,6 +125,22 @@ const totalProgress = computed(() =>
filmProgress.value,
]),
)
const isRefreshingProgress = ref(false)
const refreshStudyProgress = async () => {
if (isRefreshingProgress.value) return
isRefreshingProgress.value = true
try {
await Promise.allSettled([
basicElements.loadBasicElementsStore(),
vlog.loadVlogStore(),
interview.loadInterviewStore(),
film.loadFilmStore(),
])
} finally {
isRefreshingProgress.value = false
}
}
const CERTIFICATE_SHOWN_KEY = 'study-progress-certificate-shown'
const certificateVisible = ref(false)
@@ -140,10 +156,8 @@ const hasAllBadges = computed(() =>
const hasCertificate = computed(
() => basicElements.guideShown[CERTIFICATE_SHOWN_KEY] === true,
)
const certificateSrc = computed(() =>
getStaticImage(
`home/stuy/通关证书-${hasCertificate.value ? '已获取' : '暂未获取'}`,
),
const certificateSrc = computed(
() => `home/stuy/通关证书-${hasCertificate.value ? '已获取' : '暂未获取'}`,
)
const buildItem = (
@@ -222,6 +236,12 @@ const closeCertificate = () => {
}
}
const openCertificate = () => {
if (!hasCertificate.value) return
basicElements.ensureCertificate()
certificateVisible.value = true
}
watch([visible, hasAllBadges, hasCertificate], scheduleCertificatePopup)
onBeforeUnmount(clearCertificateTimer)
@@ -267,20 +287,39 @@ const certificateInfo = computed(() => basicElements.certificate)
@click="handleClose"
/>
<img
<ImgButton
draggable="false"
:src="certificateSrc"
:image="certificateSrc"
class="absolute -bottom-12 -right-10 object-contain"
:class="hasCertificate ? 'cursor-pointer' : 'cursor-default'"
alt=""
@click="openCertificate"
/>
<div class="absolute left-0 right-0 top-[150px] flex justify-center">
<div
class="flex items-end pr-10 text-white font-[1000] tracking-widest"
:style="{ textShadow: totalTextShadow }"
>
<span class="text-[56px] leading-none">总进度</span>
<span class="text-[56px] leading-none">{{ totalProgress }}%</span>
<div class="flex items-center gap-5">
<div
class="flex items-end text-white font-[1000] tracking-widest"
:style="{ textShadow: totalTextShadow }"
>
<span class="text-[56px] leading-none">总进度</span>
<span class="text-[56px] leading-none">{{ totalProgress }}%</span>
</div>
<button
type="button"
class="group flex items-center gap-3 rounded-full border border-[#7df8ff]/80 bg-gradient-to-r from-[#0b48c8]/70 via-[#1678ff]/70 to-[#00bfe8]/70 px-5 py-2 text-[24px] font-[1000] leading-none tracking-widest text-white shadow-[0_0_16px_rgba(67,243,242,0.45),inset_0_0_12px_rgba(255,255,255,0.18)] backdrop-blur-sm transition hover:scale-105 hover:shadow-[0_0_24px_rgba(67,243,242,0.7),inset_0_0_14px_rgba(255,255,255,0.22)] active:scale-95 disabled:cursor-wait disabled:opacity-70"
:disabled="isRefreshingProgress"
:style="{ textShadow: totalTextShadow }"
@click.stop="refreshStudyProgress"
>
<span
class="inline-flex h-7 w-7 items-center justify-center rounded-full border border-white/70 bg-white/15 text-[20px] transition group-hover:rotate-180"
:class="isRefreshingProgress ? 'animate-spin' : ''"
>
↻
</span>
<span>{{ isRefreshingProgress ? '更新中' : '更新进度' }}</span>
</button>
</div>
</div>
-13
View File
@@ -127,10 +127,6 @@ export default defineComponent({
}
}
const handleSkipIntro = () => {
showIntroVideo.value = false
}
// Loading 加载状态
const showLoading = ref(isFirstVisit)
const loadingProgress = ref(0)
@@ -251,15 +247,6 @@ export default defineComponent({
visible={showLoading.value}
progress={loadingProgress.value}
/>
{introVideoIndex.value > 0 && !showLoading.value && (
<img
class="absolute top-10 right-10 z-[10001] w-32 cursor-pointer transition-[transform,filter] duration-300 hover:scale-105 hover:brightness-110"
draggable={false}
src={getStaticImage(IMAGE_PATHS.BUTTONS.ESCAPE)}
alt="跳过"
onClick={handleSkipIntro}
/>
)}
</div>
)}
@@ -84,6 +84,13 @@ const hasUnfinishedLearningVideos = computed(() =>
completedNavigationSteps.value.some((completed) => !completed),
)
const currentVideoWatched = computed(
() =>
interview.professionVideosWatched[
navigationSteps[currentVideoIndex.value]?.id
] === true,
)
const markCurrentVideoWatched = () => {
const videoKey = navigationSteps[currentVideoIndex.value]?.id
if (videoKey) interview.markEditingVideoWatched(videoKey)
@@ -162,6 +169,7 @@ onBeforeRouteLeave((to) => {
:volume="1"
:controls="true"
:hideNativeControls="true"
:seekable="currentVideoWatched"
@ended="playNextVideo"
>
<template #overlay>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { nextTick, ref } from 'vue'
import { computed, nextTick, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { Video, GuideSteps, GuideStepsItem } from '@/components'
@@ -30,6 +30,10 @@ const handleComplete = () => {
const handleVideoEnded = () => {
interviewStore.markViewed('InterviewExportPublish')
}
const currentVideoWatched = computed(
() => interviewStore.viewed['InterviewExportPublish'] === true,
)
</script>
<template>
@@ -47,6 +51,7 @@ const handleVideoEnded = () => {
:volume="1"
:controls="true"
:hideNativeControls="true"
:seekable="currentVideoWatched"
@ended="handleVideoEnded"
/>
<ImgButton
@@ -59,7 +64,6 @@ const handleVideoEnded = () => {
v-if="showGuide"
position-offset-y="85%"
@complete="handleComplete"
@skip="handleComplete"
>
<GuideStepsItem :audio-src="getPublicPath('audio/py_3_013.MP3')">
现在一起来看看导出后的影片效果吧!
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { Video } from '@/components'
@@ -17,6 +17,10 @@ const handleBack = () => {
const handleEnded = () => {
interviewStore.markViewed('InterviewSoundEffect')
}
const currentVideoWatched = computed(
() => interviewStore.viewed['InterviewSoundEffect'] === true,
)
</script>
<template>
@@ -34,6 +38,7 @@ const handleEnded = () => {
:volume="1"
:controls="true"
:hideNativeControls="true"
:seekable="currentVideoWatched"
@ended="handleEnded"
/>
<ImgButton
-2
View File
@@ -314,7 +314,6 @@ onMounted(() => {
:steps="returnSteps"
position-offset-y="85%"
@complete="handleReturnGuideComplete"
@skip="handleReturnGuideComplete"
/>
<!-- 阶段菜单一次性引导 -->
@@ -323,7 +322,6 @@ onMounted(() => {
:steps="stageGuideSteps"
position-offset-y="85%"
@complete="handleStageGuideComplete"
@skip="handleStageGuideComplete"
/>
<ModuleMedalPopup
@@ -115,6 +115,13 @@ const hasUnfinishedLearningVideos = computed(() =>
completedNavigationSteps.value.some((completed) => !completed),
)
const currentVideoWatched = computed(
() =>
interview.professionVideosWatched[
onsiteVideoKeys[currentVideoIndex.value]
] === true,
)
const markCurrentOnsiteVideoWatched = () => {
const videoKey = onsiteVideoKeys[currentVideoIndex.value]
if (videoKey) interview.markOnsiteVideoWatched(videoKey)
@@ -198,6 +205,7 @@ onBeforeRouteLeave((to) => {
:volume="1"
:controls="true"
:hideNativeControls="true"
:seekable="currentVideoWatched"
@ended="handleEnded"
>
<template #overlay>
+45 -2
View File
@@ -240,6 +240,41 @@ const handleWheel = (event: WheelEvent): void => {
}
}
/** 触摸起点(移动端滑动翻页) */
let touchStartY = 0
let touchStartX = 0
/** 触摸点是否落在可独立滚动的内部面板(如任务要求)中 */
const isInPanelScroll = (target: EventTarget | null): boolean => {
const panel = (target as HTMLElement | null)?.closest('.task-panel')
return !!panel && panel.scrollHeight > panel.clientHeight
}
/** 记录触摸起点 */
const handleTouchStart = (event: TouchEvent): void => {
touchStartY = event.touches[0].clientY
touchStartX = event.touches[0].clientX
}
/** 阻止容器原生滚动,改由整页翻页控制(内部面板除外) */
const handleTouchMove = (event: TouchEvent): void => {
if (!isInPanelScroll(event.target)) event.preventDefault()
}
/** 根据滑动方向翻页 */
const handleTouchEnd = (event: TouchEvent): void => {
if (isAnimating.value || isInPanelScroll(event.target)) return
const deltaY = touchStartY - event.changedTouches[0].clientY
const deltaX = touchStartX - event.changedTouches[0].clientX
// 仅响应以纵向为主、且超过阈值的滑动
if (Math.abs(deltaY) < 40 || Math.abs(deltaX) > Math.abs(deltaY)) return
if (deltaY > 0) {
scrollToNextPage()
} else {
scrollToPrevPage()
}
}
const restoreScrollPage = (): void => {
const savedPage = interviewStore.practiceScrollPage
const targetPage = Math.max(0, Math.min(savedPage, TOTAL_PAGES - 1))
@@ -264,12 +299,20 @@ const saveScrollPage = (): void => {
onMounted(() => {
restoreScrollPage()
showPageGuide()
containerRef.value?.addEventListener('wheel', handleWheel, { passive: false })
const container = containerRef.value
container?.addEventListener('wheel', handleWheel, { passive: false })
container?.addEventListener('touchstart', handleTouchStart, { passive: true })
container?.addEventListener('touchmove', handleTouchMove, { passive: false })
container?.addEventListener('touchend', handleTouchEnd, { passive: true })
})
onUnmounted(() => {
saveScrollPage()
containerRef.value?.removeEventListener('wheel', handleWheel)
const container = containerRef.value
container?.removeEventListener('wheel', handleWheel)
container?.removeEventListener('touchstart', handleTouchStart)
container?.removeEventListener('touchmove', handleTouchMove)
container?.removeEventListener('touchend', handleTouchEnd)
})
</script>
@@ -32,6 +32,11 @@ type PracticeRatingScores = Record<string, number>
const workKey = computed<string>(() => decodeURIComponent(props.path))
const watchKey = computed(() => {
const key = route.query.watchKey
return typeof key === 'string' && key ? key : undefined
})
const applyScores = (
items: PracticeRatingItem[],
scores: PracticeRatingScores,
@@ -124,6 +129,9 @@ const handleBack = (): void => {
:muted="false"
:controls="true"
:loop="false"
:seekable="
watchKey ? store.practiceVideosWatched[watchKey] === true : true
"
@ended="handleVideoEnded"
/>
+8 -1
View File
@@ -255,6 +255,13 @@ const hasUnfinishedLearningVideos = computed(
completedNavigationSteps.value.some((completed) => !completed),
)
const currentVideoWatched = computed(
() =>
interview.professionVideosWatched[
professionVideoKeys[currentVideoIndex.value]
] === true,
)
const markCurrentProfessionVideoWatched = () => {
const videoKey = professionVideoKeys[currentVideoIndex.value]
if (!videoKey) return
@@ -461,6 +468,7 @@ onBeforeRouteLeave((to) => {
:volume="1"
:controls="true"
:hideNativeControls="true"
:seekable="key === 'profession' ? currentVideoWatched : true"
@play="handlePlay"
@ended="onEnded"
>
@@ -501,7 +509,6 @@ onBeforeRouteLeave((to) => {
:steps="introGuideSteps"
position-offset-y="85%"
@complete="handleIntroGuideDone"
@skip="handleIntroGuideDone"
/>
<!-- 引导与高亮 -->
-2
View File
@@ -245,7 +245,6 @@ function openImages(urls: string[], startIndex = 0) {
:highlight-padding="12"
:highlight-radius="20"
@complete="handleGuideComplete"
@skip="handleGuideComplete"
position-offset-y="85%"
/>
@@ -255,7 +254,6 @@ function openImages(urls: string[], startIndex = 0) {
:highlight-padding="12"
:highlight-radius="20"
@complete="handleReturnGuideComplete"
@skip="handleReturnGuideComplete"
position-offset-y="85%"
/>
-1
View File
@@ -704,7 +704,6 @@ const handleGuideSkip = () => {
:highlight-radius="16"
position-offset-y="85%"
@complete="handleGuideComplete"
@skip="handleGuideSkip"
/>
</div>
</template>
+5 -1
View File
@@ -63,6 +63,10 @@ const handlePracticeGuideComplete = () => {
showPracticeGuide.value = false
}
const currentVideoWatched = computed(
() => vlogStore.professionVideosWatched[COLOR_VIDEO_KEY] === true,
)
const handleBack = () => {
router.back()
}
@@ -90,6 +94,7 @@ const handleBack = () => {
:volume="1"
:controls="true"
:hideNativeControls="true"
:seekable="currentVideoWatched"
@ended="handleEnded"
>
<template #overlay>
@@ -129,7 +134,6 @@ const handleBack = () => {
highlight-selector=".practice-button"
position-offset-y="80%"
@complete="handlePracticeGuideComplete"
@skip="handlePracticeGuideComplete"
>
<GuideStepsItem :audio-src="getPublicPath('audio/py_2_024.MP3')">
点击【调色实践】,一起给案例中的图片调个色吧!
+8 -1
View File
@@ -96,6 +96,13 @@ const hasUnfinishedLearningVideos = computed(() =>
completedNavigationSteps.value.some((completed) => !completed),
)
const currentVideoWatched = computed(
() =>
vlogStore.professionVideosWatched[
navigationSteps.value[currentVideoIndex.value]?.id
] === true,
)
const markCurrentVideoWatched = () => {
const videoKey = navigationSteps.value[currentVideoIndex.value]?.id
if (videoKey) vlogStore.markEditingVideoWatched(videoKey)
@@ -180,6 +187,7 @@ onBeforeRouteLeave((to) => {
:volume="1"
:controls="true"
:hideNativeControls="true"
:seekable="currentVideoWatched"
@ended="handleEnded"
>
<template #overlay>
@@ -244,7 +252,6 @@ onBeforeRouteLeave((to) => {
v-if="showEndingGuide"
position-offset-y="85%"
@complete="handleEndingGuideComplete"
@skip="handleEndingGuideComplete"
>
<GuideStepsItem :audio-src="getPublicPath('audio/py_2_020.MP3')">
当内容结构完成后,我们还可以通过调色与包装,让画面更加统一、更具观感。
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { nextTick, ref } from 'vue'
import { computed, nextTick, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { Video, GuideSteps, GuideStepsItem } from '@/components'
@@ -32,6 +32,10 @@ const handleComplete = () => {
const handleVideoEnded = () => {
vlogStore.markViewed('VlogExportPublish')
}
const currentVideoWatched = computed(
() => vlogStore.viewed['VlogExportPublish'] === true,
)
</script>
<template>
@@ -50,6 +54,7 @@ const handleVideoEnded = () => {
:volume="1"
:controls="true"
:hideNativeControls="true"
:seekable="currentVideoWatched"
@ended="handleVideoEnded"
/>
</div>
-2
View File
@@ -348,7 +348,6 @@ onMounted(() => {
:steps="returnSteps"
position-offset-y="85%"
@complete="handleReturnGuideComplete"
@skip="handleReturnGuideComplete"
/>
<!-- 阶段菜单一次性引导 -->
@@ -357,7 +356,6 @@ onMounted(() => {
:steps="stageGuideSteps"
position-offset-y="85%"
@complete="handleStageGuideComplete"
@skip="handleStageGuideComplete"
/>
<ModuleMedalPopup
@@ -130,6 +130,13 @@ const hasUnfinishedLearningVideos = computed(() =>
completedNavigationSteps.value.some((completed) => !completed),
)
const currentVideoWatched = computed(
() =>
vlogStore.professionVideosWatched[
navigationSteps.value[currentVideoIndex.value]?.id
] === true,
)
const markCurrentVideoWatched = () => {
const videoKey = navigationSteps.value[currentVideoIndex.value]?.id
if (videoKey) vlogStore.markOnsiteVideoWatched(videoKey)
@@ -211,6 +218,7 @@ onBeforeRouteLeave((to) => {
:volume="1"
:controls="true"
:hideNativeControls="true"
:seekable="currentVideoWatched"
:anchors="[]"
@ended="handleEnded"
>
+45 -2
View File
@@ -241,6 +241,41 @@ const handleWheel = (event: WheelEvent): void => {
}
}
/** 触摸起点(移动端滑动翻页) */
let touchStartY = 0
let touchStartX = 0
/** 触摸点是否落在可独立滚动的内部面板(如任务要求)中 */
const isInPanelScroll = (target: EventTarget | null): boolean => {
const panel = (target as HTMLElement | null)?.closest('.task-panel')
return !!panel && panel.scrollHeight > panel.clientHeight
}
/** 记录触摸起点 */
const handleTouchStart = (event: TouchEvent): void => {
touchStartY = event.touches[0].clientY
touchStartX = event.touches[0].clientX
}
/** 阻止容器原生滚动,改由整页翻页控制(内部面板除外) */
const handleTouchMove = (event: TouchEvent): void => {
if (!isInPanelScroll(event.target)) event.preventDefault()
}
/** 根据滑动方向翻页 */
const handleTouchEnd = (event: TouchEvent): void => {
if (isAnimating.value || isInPanelScroll(event.target)) return
const deltaY = touchStartY - event.changedTouches[0].clientY
const deltaX = touchStartX - event.changedTouches[0].clientX
// 仅响应以纵向为主、且超过阈值的滑动
if (Math.abs(deltaY) < 40 || Math.abs(deltaX) > Math.abs(deltaY)) return
if (deltaY > 0) {
scrollToNextPage()
} else {
scrollToPrevPage()
}
}
const restoreScrollPage = (): void => {
const savedPage = vlogStore.practiceScrollPage
const targetPage = Math.max(0, Math.min(savedPage, TOTAL_PAGES - 1))
@@ -265,12 +300,20 @@ const saveScrollPage = (): void => {
onMounted(() => {
restoreScrollPage()
showPageGuide()
containerRef.value?.addEventListener('wheel', handleWheel, { passive: false })
const container = containerRef.value
container?.addEventListener('wheel', handleWheel, { passive: false })
container?.addEventListener('touchstart', handleTouchStart, { passive: true })
container?.addEventListener('touchmove', handleTouchMove, { passive: false })
container?.addEventListener('touchend', handleTouchEnd, { passive: true })
})
onUnmounted(() => {
saveScrollPage()
containerRef.value?.removeEventListener('wheel', handleWheel)
const container = containerRef.value
container?.removeEventListener('wheel', handleWheel)
container?.removeEventListener('touchstart', handleTouchStart)
container?.removeEventListener('touchmove', handleTouchMove)
container?.removeEventListener('touchend', handleTouchEnd)
})
</script>
+8
View File
@@ -32,6 +32,11 @@ type PracticeRatingScores = Record<string, number>
const workKey = computed<string>(() => decodeURIComponent(props.path))
const watchKey = computed(() => {
const key = route.query.watchKey
return typeof key === 'string' && key ? key : undefined
})
const applyScores = (
items: PracticeRatingItem[],
scores: PracticeRatingScores,
@@ -125,6 +130,9 @@ const handleBack = (): void => {
:muted="false"
:controls="true"
:loop="false"
:seekable="
watchKey ? store.practiceVideosWatched[watchKey] === true : true
"
@ended="handleVideoEnded"
/>
+8 -1
View File
@@ -160,6 +160,13 @@ const hasUnfinishedLearningVideos = computed(
completedNavigationSteps.value.some((completed) => !completed),
)
const currentVideoWatched = computed(
() =>
vlog.professionVideosWatched[
professionVideoKeys[currentVideoIndex.value]
] === true,
)
const markCurrentProfessionVideoWatched = () => {
const videoKey = professionVideoKeys[currentVideoIndex.value]
if (!videoKey) return
@@ -350,6 +357,7 @@ onBeforeRouteLeave((to) => {
:volume="1"
:controls="true"
:hideNativeControls="true"
:seekable="key === 'profession' ? currentVideoWatched : true"
:anchors="[]"
@ended="onEnded"
>
@@ -426,7 +434,6 @@ onBeforeRouteLeave((to) => {
:steps="introGuideSteps"
position-offset-y="85%"
@complete="handleIntroGuideDone"
@skip="handleIntroGuideDone"
/>
<!-- 引导与高亮 -->
-2
View File
@@ -249,7 +249,6 @@ function openImages(urls: string[], startIndex = 0) {
:highlight-padding="12"
:highlight-radius="20"
@complete="handleGuideComplete"
@skip="handleGuideComplete"
position-offset-y="85%"
/>
@@ -259,7 +258,6 @@ function openImages(urls: string[], startIndex = 0) {
:highlight-padding="12"
:highlight-radius="20"
@complete="handleReturnGuideComplete"
@skip="handleReturnGuideComplete"
position-offset-y="85%"
/>