feat: 优化移动端学习与证书体验
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
dd233e102b
commit
680e8b97d7
55 files changed
+513
-230
No files matched your search
+2
-1
@@ -9,8 +9,9 @@
|
|||||||
/>
|
/>
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0"
|
content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<meta
|
<meta
|
||||||
name="referrer"
|
name="referrer"
|
||||||
content="no-referrer"
|
content="no-referrer"
|
||||||
|
|||||||
+4
-1
@@ -7,7 +7,10 @@
|
|||||||
class="app-content overflow-hidden"
|
class="app-content overflow-hidden"
|
||||||
:style="scaleStyle"
|
:style="scaleStyle"
|
||||||
>
|
>
|
||||||
<router-view v-slot="{ Component }">
|
<router-view
|
||||||
|
v-if="!bootstrapLoading"
|
||||||
|
v-slot="{ Component }"
|
||||||
|
>
|
||||||
<KeepAlive :include="['Action', 'Skill']">
|
<KeepAlive :include="['Action', 'Skill']">
|
||||||
<component :is="Component" />
|
<component :is="Component" />
|
||||||
</KeepAlive>
|
</KeepAlive>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 2.3 MiB |
@@ -6,7 +6,10 @@
|
|||||||
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.GUIDE_DRAGON_BG) }"></div> -->
|
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.GUIDE_DRAGON_BG) }"></div> -->
|
||||||
|
|
||||||
<!-- 导学小龙UI区域 -->
|
<!-- 导学小龙UI区域 -->
|
||||||
<div class="guide-ui absolute left-16" :style="positionStyle">
|
<div
|
||||||
|
class="guide-ui absolute left-16"
|
||||||
|
:style="positionStyle"
|
||||||
|
>
|
||||||
<!-- 主UI面板 -->
|
<!-- 主UI面板 -->
|
||||||
<div
|
<div
|
||||||
class="guide-panel bg-no-repeat"
|
class="guide-panel bg-no-repeat"
|
||||||
@@ -14,15 +17,11 @@
|
|||||||
>
|
>
|
||||||
<!-- 标题区域 -->
|
<!-- 标题区域 -->
|
||||||
|
|
||||||
<ImgButton
|
|
||||||
v-if="showSkip"
|
|
||||||
:image="IMAGE_PATHS.BUTTONS.ESCAPE"
|
|
||||||
@click="handleSkip"
|
|
||||||
class="skip-button"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 内容区域 -->
|
<!-- 内容区域 -->
|
||||||
<div class="guide-content" @click="handleContinue">
|
<div
|
||||||
|
class="guide-content"
|
||||||
|
@click="handleContinue"
|
||||||
|
>
|
||||||
<!-- <div class="content-icon">
|
<!-- <div class="content-icon">
|
||||||
<div class="icon-outer"></div>
|
<div class="icon-outer"></div>
|
||||||
<div class="icon-inner"></div>
|
<div class="icon-inner"></div>
|
||||||
@@ -57,10 +56,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 小龙角色区域 -->
|
<!-- 小龙角色区域 -->
|
||||||
<div class="dragon-character absolute right-16" :style="positionStyle">
|
<div
|
||||||
|
class="dragon-character absolute right-16"
|
||||||
|
:style="positionStyle"
|
||||||
|
>
|
||||||
<div class="dragon-container">
|
<div class="dragon-container">
|
||||||
<!-- 小龙角色图片 -->
|
<!-- 小龙角色图片 -->
|
||||||
<img :src="getStaticImage(IMAGE_PATHS.GUIDE_PERSON)" alt="导学小龙" class="dragon-image" />
|
<img
|
||||||
|
:src="getStaticImage(IMAGE_PATHS.GUIDE_PERSON)"
|
||||||
|
alt="导学小龙"
|
||||||
|
class="dragon-image"
|
||||||
|
/>
|
||||||
<!-- 发光效果 -->
|
<!-- 发光效果 -->
|
||||||
<div class="dragon-glow"></div>
|
<div class="dragon-glow"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,8 +80,6 @@ import { getStaticImage, getBackgroundImage, IMAGE_PATHS } from '@/utils'
|
|||||||
import { ImgButton } from '@/components/Button'
|
import { ImgButton } from '@/components/Button'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** 是否显示跳过按钮 */
|
|
||||||
showSkip?: boolean
|
|
||||||
/** 当前步骤 */
|
/** 当前步骤 */
|
||||||
currentStep?: number
|
currentStep?: number
|
||||||
/** 总步骤数 */
|
/** 总步骤数 */
|
||||||
@@ -86,28 +90,16 @@ interface Props {
|
|||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
(e: 'next'): void
|
(e: 'next'): void
|
||||||
(e: 'skip'): void
|
|
||||||
(e: 'complete'): void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
showSkip: true,
|
|
||||||
currentStep: 1,
|
currentStep: 1,
|
||||||
totalSteps: 1,
|
totalSteps: 1,
|
||||||
positionOffsetY: '50%'
|
positionOffsetY: '50%',
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
// 处理跳过
|
|
||||||
const handleSkip = () => {
|
|
||||||
try {
|
|
||||||
emit('skip')
|
|
||||||
} catch (error) {
|
|
||||||
console.error('GuideDragon handleSkip error:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//继续
|
//继续
|
||||||
const handleContinue = () => {
|
const handleContinue = () => {
|
||||||
emit('next')
|
emit('next')
|
||||||
@@ -122,7 +114,10 @@ const normalizeOffset = (value: number | string) => {
|
|||||||
const positionStyle = computed(() => {
|
const positionStyle = computed(() => {
|
||||||
const y = normalizeOffset(props.positionOffsetY)
|
const y = normalizeOffset(props.positionOffsetY)
|
||||||
// 百分比:按中心定位;px:按像素直接定位
|
// 百分比:按中心定位;px:按像素直接定位
|
||||||
if (typeof props.positionOffsetY === 'string' && props.positionOffsetY.includes('%')) {
|
if (
|
||||||
|
typeof props.positionOffsetY === 'string' &&
|
||||||
|
props.positionOffsetY.includes('%')
|
||||||
|
) {
|
||||||
return { top: y, transform: 'translateY(-50%)' }
|
return { top: y, transform: 'translateY(-50%)' }
|
||||||
}
|
}
|
||||||
return { top: y, transform: 'translateY(0)' }
|
return { top: y, transform: 'translateY(0)' }
|
||||||
@@ -169,13 +164,6 @@ const positionStyle = computed(() => {
|
|||||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.skip-button {
|
|
||||||
@apply text-white text-sm underline hover:no-underline;
|
|
||||||
position: absolute;
|
|
||||||
right: 30px;
|
|
||||||
top: 35px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.guide-content {
|
.guide-content {
|
||||||
@apply flex items-start pl-6 pt-9 pb-0 h-full;
|
@apply flex items-start pl-6 pt-9 pb-0 h-full;
|
||||||
}
|
}
|
||||||
@@ -235,7 +223,11 @@ const positionStyle = computed(() => {
|
|||||||
|
|
||||||
.dragon-glow {
|
.dragon-glow {
|
||||||
@apply absolute inset-0 rounded-full;
|
@apply absolute inset-0 rounded-full;
|
||||||
background: radial-gradient(circle, rgba(255, 255, 0, 0.2) 0%, transparent 70%);
|
background: radial-gradient(
|
||||||
|
circle,
|
||||||
|
rgba(255, 255, 0, 0.2) 0%,
|
||||||
|
transparent 70%
|
||||||
|
);
|
||||||
animation: glow 2s ease-in-out infinite alternate;
|
animation: glow 2s ease-in-out infinite alternate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,13 +16,10 @@
|
|||||||
:style="alignStyle"
|
:style="alignStyle"
|
||||||
>
|
>
|
||||||
<GuideDragon
|
<GuideDragon
|
||||||
:show-skip="currentStep < totalSteps"
|
|
||||||
:current-step="currentStep"
|
:current-step="currentStep"
|
||||||
:total-steps="totalSteps"
|
:total-steps="totalSteps"
|
||||||
:position-offset-y="currentPositionOffsetY"
|
:position-offset-y="currentPositionOffsetY"
|
||||||
@next="handleNext"
|
@next="handleNext"
|
||||||
@skip="handleSkip"
|
|
||||||
@complete="handleComplete"
|
|
||||||
>
|
>
|
||||||
<template #default>
|
<template #default>
|
||||||
<!-- 支持插槽形式的步骤内容 -->
|
<!-- 支持插槽形式的步骤内容 -->
|
||||||
@@ -101,7 +98,6 @@ interface Props {
|
|||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
(e: 'complete'): void
|
(e: 'complete'): void
|
||||||
(e: 'skip'): void
|
|
||||||
(e: 'close'): void
|
(e: 'close'): void
|
||||||
(e: 'step-change', step: GuideStep | null, index: number): void
|
(e: 'step-change', step: GuideStep | null, index: number): void
|
||||||
}
|
}
|
||||||
@@ -233,11 +229,6 @@ const handleNext = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSkip = () => {
|
|
||||||
emit('skip')
|
|
||||||
handleComplete()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleComplete = () => {
|
const handleComplete = () => {
|
||||||
internalShow.value = false
|
internalShow.value = false
|
||||||
emit('complete')
|
emit('complete')
|
||||||
@@ -288,10 +279,6 @@ const handleKeydown = (event: KeyboardEvent) => {
|
|||||||
emit('step-change', currentStepData.value, currentStepIndex.value)
|
emit('step-change', currentStepData.value, currentStepIndex.value)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case 'Escape':
|
|
||||||
event.preventDefault()
|
|
||||||
handleSkip()
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,11 +80,8 @@ interface GuideStep {
|
|||||||
```vue
|
```vue
|
||||||
<template>
|
<template>
|
||||||
<GuideDragon
|
<GuideDragon
|
||||||
:show-skip="true"
|
|
||||||
next-button-text="下一步"
|
next-button-text="下一步"
|
||||||
@next="handleNext"
|
@next="handleNext"
|
||||||
@skip="handleSkip"
|
|
||||||
@complete="handleComplete"
|
|
||||||
>
|
>
|
||||||
<h3>欢迎使用短视频达人!</h3>
|
<h3>欢迎使用短视频达人!</h3>
|
||||||
<p>让我来为你介绍如何使用这个应用吧~</p>
|
<p>让我来为你介绍如何使用这个应用吧~</p>
|
||||||
@@ -98,10 +95,6 @@ const handleNext = () => {
|
|||||||
console.log('下一步')
|
console.log('下一步')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSkip = () => {
|
|
||||||
console.log('跳过')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleComplete = () => {
|
const handleComplete = () => {
|
||||||
console.log('完成')
|
console.log('完成')
|
||||||
}
|
}
|
||||||
@@ -112,7 +105,10 @@ const handleComplete = () => {
|
|||||||
|
|
||||||
```vue
|
```vue
|
||||||
<template>
|
<template>
|
||||||
<GuideSteps :steps="guideSteps" @complete="handleComplete" @skip="handleSkip" />
|
<GuideSteps
|
||||||
|
:steps="guideSteps"
|
||||||
|
@complete="handleComplete"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -122,13 +118,13 @@ const guideSteps = [
|
|||||||
{
|
{
|
||||||
id: 'welcome',
|
id: 'welcome',
|
||||||
content: '欢迎来到短视频达人应用!我是你的导学小龙...',
|
content: '欢迎来到短视频达人应用!我是你的导学小龙...',
|
||||||
nextText: '开始学习'
|
nextText: '开始学习',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'features',
|
id: 'features',
|
||||||
content: '我们的应用包含以下核心功能...',
|
content: '我们的应用包含以下核心功能...',
|
||||||
nextText: '了解详情'
|
nextText: '了解详情',
|
||||||
}
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const handleComplete = () => {
|
const handleComplete = () => {
|
||||||
@@ -141,7 +137,7 @@ const handleComplete = () => {
|
|||||||
|
|
||||||
```vue
|
```vue
|
||||||
<template>
|
<template>
|
||||||
<GuideSteps @complete="handleComplete" @skip="handleSkip">
|
<GuideSteps @complete="handleComplete">
|
||||||
<GuideStepsItem>欢迎来到短视频达人应用!我是你的导学小龙</GuideStepsItem>
|
<GuideStepsItem>欢迎来到短视频达人应用!我是你的导学小龙</GuideStepsItem>
|
||||||
<GuideStepsItem>
|
<GuideStepsItem>
|
||||||
<p>
|
<p>
|
||||||
@@ -170,7 +166,6 @@ const handleComplete = () => {
|
|||||||
<template>
|
<template>
|
||||||
<GuideSteps
|
<GuideSteps
|
||||||
@complete="handleComplete"
|
@complete="handleComplete"
|
||||||
@skip="handleSkip"
|
|
||||||
:keyboard-navigation="true"
|
:keyboard-navigation="true"
|
||||||
:auto-play-interval="3000"
|
:auto-play-interval="3000"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ export interface GuideStep {
|
|||||||
|
|
||||||
// 引导组件Props类型
|
// 引导组件Props类型
|
||||||
export interface GuideDragonProps {
|
export interface GuideDragonProps {
|
||||||
showSkip?: boolean
|
|
||||||
currentStep?: number
|
currentStep?: number
|
||||||
totalSteps?: number
|
totalSteps?: number
|
||||||
/** 垂直偏移(仅Y轴),支持 number(px) 或 '10%'/'120px' */
|
/** 垂直偏移(仅Y轴),支持 number(px) 或 '10%'/'120px' */
|
||||||
@@ -48,13 +47,11 @@ export interface GuideStepsProps {
|
|||||||
// 引导组件事件类型
|
// 引导组件事件类型
|
||||||
export interface GuideDragonEmits {
|
export interface GuideDragonEmits {
|
||||||
(e: 'next'): void
|
(e: 'next'): void
|
||||||
(e: 'skip'): void
|
|
||||||
(e: 'complete'): void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GuideStepsEmits {
|
export interface GuideStepsEmits {
|
||||||
(e: 'complete'): void
|
(e: 'complete'): void
|
||||||
(e: 'skip'): void
|
(e: 'close'): void
|
||||||
(e: 'step-change', step: GuideStep | null, index: number): void
|
(e: 'step-change', step: GuideStep | null, index: number): void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -393,51 +393,4 @@ button:disabled {
|
|||||||
filter: brightness(0) saturate(100%) invert(79%) sepia(29%) saturate(1190%)
|
filter: brightness(0) saturate(100%) invert(79%) sepia(29%) saturate(1190%)
|
||||||
hue-rotate(330deg) brightness(102%) contrast(98%);
|
hue-rotate(330deg) brightness(102%) contrast(98%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 响应式设计 */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.progress-bar {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 15px;
|
|
||||||
padding: 20px 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-section {
|
|
||||||
min-width: auto;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.steps-section {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-item {
|
|
||||||
flex-direction: row;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-dots {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-image {
|
|
||||||
width: 80px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.progress-bar {
|
|
||||||
padding: 15px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-text {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-image {
|
|
||||||
width: 70px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -233,7 +233,9 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 flex items-center justify-center px-20 pointer-events-none z-30"
|
class="absolute inset-0 flex items-center justify-center px-20 pointer-events-auto z-30"
|
||||||
|
@touchstart.stop
|
||||||
|
@click.stop
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="bg-black/80 backdrop-blur-lg rounded-3xl border border-white/20 p-16 space-y-10 text-white pointer-events-auto"
|
class="bg-black/80 backdrop-blur-lg rounded-3xl border border-white/20 p-16 space-y-10 text-white pointer-events-auto"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { isMobile } from '@/utils/device'
|
||||||
import DefaultControls from './DefaultControls.vue'
|
import DefaultControls from './DefaultControls.vue'
|
||||||
import type {
|
import type {
|
||||||
PlayAttemptReason,
|
PlayAttemptReason,
|
||||||
@@ -23,6 +24,7 @@ const props = withDefaults(defineProps<VideoProps>(), {
|
|||||||
playbackRate: 1,
|
playbackRate: 1,
|
||||||
fit: 'contain',
|
fit: 'contain',
|
||||||
anchors: () => [],
|
anchors: () => [],
|
||||||
|
seekable: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -58,6 +60,36 @@ defineSlots<{
|
|||||||
|
|
||||||
const videoRef = ref<HTMLVideoElement>()
|
const videoRef = ref<HTMLVideoElement>()
|
||||||
|
|
||||||
|
// 丝滑进度条:用 requestAnimationFrame 驱动高频更新
|
||||||
|
let rafId: number | undefined
|
||||||
|
|
||||||
|
const startProgressRaf = () => {
|
||||||
|
if (rafId !== undefined) return
|
||||||
|
const tick = () => {
|
||||||
|
const el = videoRef.value
|
||||||
|
if (!el) {
|
||||||
|
rafId = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentTime.value = el.currentTime
|
||||||
|
if (el.buffered.length) {
|
||||||
|
buffered.value = el.buffered.end(el.buffered.length - 1)
|
||||||
|
}
|
||||||
|
if (!isSeeking.value) {
|
||||||
|
duration.value = el.duration || duration.value
|
||||||
|
}
|
||||||
|
rafId = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
rafId = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopProgressRaf = () => {
|
||||||
|
if (rafId !== undefined) {
|
||||||
|
cancelAnimationFrame(rafId)
|
||||||
|
rafId = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 本地状态
|
// 本地状态
|
||||||
const isPlaying = ref(false)
|
const isPlaying = ref(false)
|
||||||
const isSeeking = ref(false)
|
const isSeeking = ref(false)
|
||||||
@@ -94,7 +126,7 @@ const clearHideTimer = () => {
|
|||||||
|
|
||||||
const scheduleHideIfNeeded = () => {
|
const scheduleHideIfNeeded = () => {
|
||||||
clearHideTimer()
|
clearHideTimer()
|
||||||
if (!props.autoHideControls) return
|
if (!shouldAutoHide.value) return
|
||||||
if (!isPlaying.value) return
|
if (!isPlaying.value) return
|
||||||
hideTimer = window.setTimeout(() => {
|
hideTimer = window.setTimeout(() => {
|
||||||
showUi.value = false
|
showUi.value = false
|
||||||
@@ -117,6 +149,8 @@ const bufferedPercent = computed(() =>
|
|||||||
duration.value ? (buffered.value / duration.value) * 100 : 0,
|
duration.value ? (buffered.value / duration.value) * 100 : 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const shouldAutoHide = computed(() => props.autoHideControls && !isMobile())
|
||||||
|
|
||||||
const handleAnchorsOnTimeUpdate = (t: number, el: HTMLVideoElement) => {
|
const handleAnchorsOnTimeUpdate = (t: number, el: HTMLVideoElement) => {
|
||||||
if (!props.anchors.length || isHandlingAnchor.value) return
|
if (!props.anchors.length || isHandlingAnchor.value) return
|
||||||
|
|
||||||
@@ -214,6 +248,7 @@ const resetSessionState = () => {
|
|||||||
currentTime.value = 0
|
currentTime.value = 0
|
||||||
duration.value = 0
|
duration.value = 0
|
||||||
buffered.value = 0
|
buffered.value = 0
|
||||||
|
stopProgressRaf()
|
||||||
activeAnchorId.value = null
|
activeAnchorId.value = null
|
||||||
isHandlingAnchor.value = false
|
isHandlingAnchor.value = false
|
||||||
hiddenAnchorIds.value = new Set()
|
hiddenAnchorIds.value = new Set()
|
||||||
@@ -357,6 +392,7 @@ const handleNativePlay = () => {
|
|||||||
const el = videoRef.value
|
const el = videoRef.value
|
||||||
if (el) applyPlaybackRate(el)
|
if (el) applyPlaybackRate(el)
|
||||||
isPlaying.value = true
|
isPlaying.value = true
|
||||||
|
startProgressRaf()
|
||||||
emit('update:modelValue', true)
|
emit('update:modelValue', true)
|
||||||
emit('play')
|
emit('play')
|
||||||
revealTemporarily()
|
revealTemporarily()
|
||||||
@@ -364,6 +400,7 @@ const handleNativePlay = () => {
|
|||||||
|
|
||||||
const handleNativePause = () => {
|
const handleNativePause = () => {
|
||||||
isPlaying.value = false
|
isPlaying.value = false
|
||||||
|
stopProgressRaf()
|
||||||
if (!consumeSuppressedPause()) {
|
if (!consumeSuppressedPause()) {
|
||||||
emit('update:modelValue', false)
|
emit('update:modelValue', false)
|
||||||
emit('pause')
|
emit('pause')
|
||||||
@@ -375,15 +412,10 @@ const handleNativePause = () => {
|
|||||||
const handleNativeEnded = () => emit('ended')
|
const handleNativeEnded = () => emit('ended')
|
||||||
|
|
||||||
const handleNativeTimeUpdate = () => {
|
const handleNativeTimeUpdate = () => {
|
||||||
|
// 仅用于锚点检测;进度条更新由 RAF 驱动
|
||||||
const el = videoRef.value
|
const el = videoRef.value
|
||||||
if (!el) return
|
if (!el) return
|
||||||
currentTime.value = el.currentTime
|
handleAnchorsOnTimeUpdate(el.currentTime, el)
|
||||||
if (el.buffered.length) {
|
|
||||||
buffered.value = el.buffered.end(el.buffered.length - 1)
|
|
||||||
}
|
|
||||||
duration.value = el.duration || duration.value
|
|
||||||
emit('timeupdate', currentTime.value, duration.value)
|
|
||||||
handleAnchorsOnTimeUpdate(currentTime.value, el)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleNativeLoadedMetadata = () => {
|
const handleNativeLoadedMetadata = () => {
|
||||||
@@ -494,6 +526,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
if (!el) return
|
if (!el) return
|
||||||
unbindVideoEvents(el)
|
unbindVideoEvents(el)
|
||||||
|
stopProgressRaf()
|
||||||
el.src = ''
|
el.src = ''
|
||||||
el.load()
|
el.load()
|
||||||
})
|
})
|
||||||
@@ -507,6 +540,7 @@ const slotProps = computed<VideoSlotProps>(() => ({
|
|||||||
bufferedPercent: bufferedPercent.value,
|
bufferedPercent: bufferedPercent.value,
|
||||||
volume: internalVolume.value,
|
volume: internalVolume.value,
|
||||||
rate: internalRate.value,
|
rate: internalRate.value,
|
||||||
|
seekable: props.seekable ?? true,
|
||||||
togglePlay,
|
togglePlay,
|
||||||
seek,
|
seek,
|
||||||
setVolume: handleVolume,
|
setVolume: handleVolume,
|
||||||
@@ -552,6 +586,8 @@ const videoStyle = computed(() => ({
|
|||||||
playsinline
|
playsinline
|
||||||
webkit-playsinline="true"
|
webkit-playsinline="true"
|
||||||
x5-playsinline="true"
|
x5-playsinline="true"
|
||||||
|
x5-video-player-type="h5"
|
||||||
|
x5-video-player-fullscreen="true"
|
||||||
:style="videoStyle"
|
:style="videoStyle"
|
||||||
@click="togglePlay"
|
@click="togglePlay"
|
||||||
class="w-full h-full block bg-black"
|
class="w-full h-full block bg-black"
|
||||||
@@ -605,11 +641,9 @@ const videoStyle = computed(() => ({
|
|||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
<!-- 控制条插槽(可完全自定义),提供默认实现作为后备 -->
|
<!-- 控制条插槽(可完全自定义),提供默认实现作为后备 -->
|
||||||
|
<!-- 完整控制栏:受 showUi 控制自动隐藏;首次学习永不显示 -->
|
||||||
<Transition name="sv-slide-up">
|
<Transition name="sv-slide-up">
|
||||||
<div
|
<div v-if="controls && showUi && props.seekable !== false">
|
||||||
v-if="controls"
|
|
||||||
v-show="showUi"
|
|
||||||
>
|
|
||||||
<slot
|
<slot
|
||||||
name="controls"
|
name="controls"
|
||||||
v-bind="slotProps"
|
v-bind="slotProps"
|
||||||
@@ -639,6 +673,21 @@ const videoStyle = computed(() => ({
|
|||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
|
<!-- 轻量进度提示条:
|
||||||
|
首次学习(seekable=false)始终显示;
|
||||||
|
复看时完整控制栏隐藏后出现(B站模式) -->
|
||||||
|
<div
|
||||||
|
v-if="controls && (!showUi || props.seekable === false)"
|
||||||
|
class="sv-mini-progress absolute left-0 right-0 bottom-0 z-10"
|
||||||
|
>
|
||||||
|
<div class="w-full h-[3px] bg-white/20">
|
||||||
|
<div
|
||||||
|
class="h-full bg-blue-500 transition-[width] duration-300"
|
||||||
|
:style="{ width: progressPercent + '%' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export interface VideoProps {
|
|||||||
playbackRate?: number
|
playbackRate?: number
|
||||||
fit?: 'contain' | 'cover' | 'fill'
|
fit?: 'contain' | 'cover' | 'fill'
|
||||||
anchors?: VideoAnchor[]
|
anchors?: VideoAnchor[]
|
||||||
|
seekable?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoSlotProps {
|
export interface VideoSlotProps {
|
||||||
@@ -53,6 +54,7 @@ export interface VideoSlotProps {
|
|||||||
bufferedPercent: number
|
bufferedPercent: number
|
||||||
volume: number
|
volume: number
|
||||||
rate: number
|
rate: number
|
||||||
|
seekable: boolean
|
||||||
togglePlay: () => Promise<void>
|
togglePlay: () => Promise<void>
|
||||||
seek: (time: number) => void
|
seek: (time: number) => void
|
||||||
setVolume: (volume: number) => void
|
setVolume: (volume: number) => void
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* 设备检测工具
|
||||||
|
* 基于 User-Agent 判断当前是否为移动端设备
|
||||||
|
*/
|
||||||
|
export function isMobile(): boolean {
|
||||||
|
if (typeof window === 'undefined') return false
|
||||||
|
const ua = window.navigator.userAgent.toLowerCase()
|
||||||
|
return /iphone|ipad|ipod|android|mobile/.test(ua)
|
||||||
|
}
|
||||||
@@ -111,6 +111,13 @@ const markCurrentLearningVideoWatched = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentVideoWatched = computed(
|
||||||
|
() =>
|
||||||
|
basicElementsStore.learningVideosWatched[
|
||||||
|
getLearningVideoKey(currentVideoIndex.value)
|
||||||
|
] === true,
|
||||||
|
)
|
||||||
|
|
||||||
//按顺序播放
|
//按顺序播放
|
||||||
const currentVideo = computed(
|
const currentVideo = computed(
|
||||||
() => navigationSteps.value[currentVideoIndex.value].videoUrl,
|
() => navigationSteps.value[currentVideoIndex.value].videoUrl,
|
||||||
@@ -334,6 +341,7 @@ onBeforeRouteLeave((to) => {
|
|||||||
:controls="true"
|
:controls="true"
|
||||||
:hideNativeControls="true"
|
:hideNativeControls="true"
|
||||||
:anchors="[]"
|
:anchors="[]"
|
||||||
|
:seekable="currentVideoWatched"
|
||||||
@play="handlePlay"
|
@play="handlePlay"
|
||||||
@ended="onEnded"
|
@ended="onEnded"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -103,6 +103,13 @@ const markCurrentLearningVideoWatched = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentVideoWatched = computed(
|
||||||
|
() =>
|
||||||
|
basicElementsStore.learningVideosWatched[
|
||||||
|
getLearningVideoKey(currentVideoIndex.value)
|
||||||
|
] === true,
|
||||||
|
)
|
||||||
|
|
||||||
//按顺序播放
|
//按顺序播放
|
||||||
const currentVideo = computed(
|
const currentVideo = computed(
|
||||||
() => navigationSteps.value[currentVideoIndex.value].videoUrl,
|
() => navigationSteps.value[currentVideoIndex.value].videoUrl,
|
||||||
@@ -205,6 +212,7 @@ onBeforeRouteLeave((to) => {
|
|||||||
:controls="true"
|
:controls="true"
|
||||||
:hideNativeControls="true"
|
:hideNativeControls="true"
|
||||||
:anchors="[]"
|
:anchors="[]"
|
||||||
|
:seekable="currentVideoWatched"
|
||||||
@play="handlePlay"
|
@play="handlePlay"
|
||||||
@ended="onEnded"
|
@ended="onEnded"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -74,6 +74,13 @@ const hasUnfinishedLearningVideos = computed(() =>
|
|||||||
completedNavigationSteps.value.some((completed) => !completed),
|
completedNavigationSteps.value.some((completed) => !completed),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const currentVideoWatched = computed(
|
||||||
|
() =>
|
||||||
|
basicElementsStore.learningVideosWatched[
|
||||||
|
getLearningVideoKey(currentVideoIndex.value)
|
||||||
|
] === true,
|
||||||
|
)
|
||||||
|
|
||||||
const markCurrentLearningVideoWatched = () => {
|
const markCurrentLearningVideoWatched = () => {
|
||||||
basicElementsStore.markLearningVideoWatched(
|
basicElementsStore.markLearningVideoWatched(
|
||||||
getLearningVideoKey(currentVideoIndex.value),
|
getLearningVideoKey(currentVideoIndex.value),
|
||||||
@@ -184,6 +191,7 @@ onMounted(() => {})
|
|||||||
:controls="true"
|
:controls="true"
|
||||||
:hideNativeControls="true"
|
:hideNativeControls="true"
|
||||||
:anchors="[]"
|
:anchors="[]"
|
||||||
|
:seekable="currentVideoWatched"
|
||||||
@play="handlePlay"
|
@play="handlePlay"
|
||||||
@ended="onEnded"
|
@ended="onEnded"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -106,6 +106,13 @@ const markCurrentLearningVideoWatched = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentVideoWatched = computed(
|
||||||
|
() =>
|
||||||
|
basicElementsStore.learningVideosWatched[
|
||||||
|
getLearningVideoKey(currentVideoIndex.value)
|
||||||
|
] === true,
|
||||||
|
)
|
||||||
|
|
||||||
//按顺序播放
|
//按顺序播放
|
||||||
const currentVideo = computed(
|
const currentVideo = computed(
|
||||||
() => navigationSteps.value[currentVideoIndex.value].videoUrl,
|
() => navigationSteps.value[currentVideoIndex.value].videoUrl,
|
||||||
@@ -266,6 +273,7 @@ onBeforeRouteLeave((to) => {
|
|||||||
:controls="true"
|
:controls="true"
|
||||||
:hideNativeControls="true"
|
:hideNativeControls="true"
|
||||||
:anchors="[]"
|
:anchors="[]"
|
||||||
|
:seekable="currentVideoWatched"
|
||||||
@play="handlePlay"
|
@play="handlePlay"
|
||||||
@ended="onEnded"
|
@ended="onEnded"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -148,6 +148,13 @@ const markCurrentLearningVideoWatched = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentVideoWatched = computed(
|
||||||
|
() =>
|
||||||
|
basicElementsStore.learningVideosWatched[
|
||||||
|
getLearningVideoKey(currentVideoIndex.value)
|
||||||
|
] === true,
|
||||||
|
)
|
||||||
|
|
||||||
//按顺序播放
|
//按顺序播放
|
||||||
const currentVideo = computed(() => rawSteps[currentVideoIndex.value].videoUrl)
|
const currentVideo = computed(() => rawSteps[currentVideoIndex.value].videoUrl)
|
||||||
|
|
||||||
@@ -248,6 +255,7 @@ onBeforeRouteLeave((to) => {
|
|||||||
:controls="true"
|
:controls="true"
|
||||||
:hideNativeControls="true"
|
:hideNativeControls="true"
|
||||||
:anchors="[]"
|
:anchors="[]"
|
||||||
|
:seekable="currentVideoWatched"
|
||||||
@play="handlePlay"
|
@play="handlePlay"
|
||||||
@ended="onEnded"
|
@ended="onEnded"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ const back = () => {
|
|||||||
controls
|
controls
|
||||||
:muted="false"
|
:muted="false"
|
||||||
:volume="1"
|
:volume="1"
|
||||||
|
:seekable="filmStore.viewed['FilmColor'] === true"
|
||||||
@ended="handleVideoEnded"
|
@ended="handleVideoEnded"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const handleVideoEnded = () => {
|
|||||||
:controls="true"
|
:controls="true"
|
||||||
:hideNativeControls="true"
|
:hideNativeControls="true"
|
||||||
:autoplay="true"
|
:autoplay="true"
|
||||||
|
:seekable="film.editingVideosWatched['association'] === true"
|
||||||
:show-center-play-icon="true"
|
:show-center-play-icon="true"
|
||||||
@ended="handleVideoEnded"
|
@ended="handleVideoEnded"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const handleVideoEnded = () => {
|
|||||||
:controls="true"
|
:controls="true"
|
||||||
:hideNativeControls="true"
|
:hideNativeControls="true"
|
||||||
:autoplay="true"
|
:autoplay="true"
|
||||||
|
:seekable="film.editingVideosWatched['continuity'] === true"
|
||||||
:show-center-play-icon="true"
|
:show-center-play-icon="true"
|
||||||
@ended="handleVideoEnded"
|
@ended="handleVideoEnded"
|
||||||
></Video>
|
></Video>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const handleVideoEnded = () => {
|
|||||||
:controls="true"
|
:controls="true"
|
||||||
:hideNativeControls="true"
|
:hideNativeControls="true"
|
||||||
:autoplay="true"
|
:autoplay="true"
|
||||||
|
:seekable="film.editingVideosWatched['cut'] === true"
|
||||||
:show-center-play-icon="true"
|
:show-center-play-icon="true"
|
||||||
@ended="handleVideoEnded"
|
@ended="handleVideoEnded"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -213,7 +213,6 @@ const handleEditingCompleteGuideDone = () => {
|
|||||||
:steps="editingCompleteSteps"
|
:steps="editingCompleteSteps"
|
||||||
position-offset-y="85%"
|
position-offset-y="85%"
|
||||||
@complete="handleEditingCompleteGuideDone"
|
@complete="handleEditingCompleteGuideDone"
|
||||||
@skip="handleEditingCompleteGuideDone"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const handleVideoEnded = () => {
|
|||||||
:controls="true"
|
:controls="true"
|
||||||
:hideNativeControls="true"
|
:hideNativeControls="true"
|
||||||
:autoplay="true"
|
:autoplay="true"
|
||||||
|
:seekable="film.editingVideosWatched['rhythm'] === true"
|
||||||
:show-center-play-icon="true"
|
:show-center-play-icon="true"
|
||||||
@ended="handleVideoEnded"
|
@ended="handleVideoEnded"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const handleVideoEnded = () => {
|
|||||||
:controls="true"
|
:controls="true"
|
||||||
:hideNativeControls="true"
|
:hideNativeControls="true"
|
||||||
:autoplay="true"
|
:autoplay="true"
|
||||||
|
:seekable="film.editingVideosWatched['start'] === true"
|
||||||
:show-center-play-icon="true"
|
:show-center-play-icon="true"
|
||||||
@ended="handleVideoEnded"
|
@ended="handleVideoEnded"
|
||||||
></Video>
|
></Video>
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ const handleVideoEnded = () => {
|
|||||||
:volume="1"
|
:volume="1"
|
||||||
:controls="true"
|
:controls="true"
|
||||||
:hideNativeControls="true"
|
:hideNativeControls="true"
|
||||||
|
:seekable="filmStore.viewed['FilmExportPublish'] === true"
|
||||||
@ended="handleVideoEnded"
|
@ended="handleVideoEnded"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -64,7 +65,6 @@ const handleVideoEnded = () => {
|
|||||||
v-if="showGuide"
|
v-if="showGuide"
|
||||||
position-offset-y="85%"
|
position-offset-y="85%"
|
||||||
@complete="handleComplete"
|
@complete="handleComplete"
|
||||||
@skip="handleComplete"
|
|
||||||
>
|
>
|
||||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_4_019.MP3')">
|
<GuideStepsItem :audio-src="getPublicPath('audio/py_4_019.MP3')">
|
||||||
现在一起来看看导出后的影片效果吧!
|
现在一起来看看导出后的影片效果吧!
|
||||||
|
|||||||
@@ -384,7 +384,6 @@ onMounted(() => {
|
|||||||
:steps="returnSteps"
|
:steps="returnSteps"
|
||||||
position-offset-y="85%"
|
position-offset-y="85%"
|
||||||
@complete="handleReturnGuideComplete"
|
@complete="handleReturnGuideComplete"
|
||||||
@skip="handleReturnGuideComplete"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 阶段菜单一次性引导(中期拍摄 / 后期剪辑) -->
|
<!-- 阶段菜单一次性引导(中期拍摄 / 后期剪辑) -->
|
||||||
@@ -393,7 +392,6 @@ onMounted(() => {
|
|||||||
:steps="stageGuideSteps"
|
:steps="stageGuideSteps"
|
||||||
position-offset-y="85%"
|
position-offset-y="85%"
|
||||||
@complete="handleStageGuideComplete"
|
@complete="handleStageGuideComplete"
|
||||||
@skip="handleStageGuideComplete"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- <GuideSteps
|
<!-- <GuideSteps
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const handleVideoEnded = () => {
|
|||||||
:controls="true"
|
:controls="true"
|
||||||
:hideNativeControls="true"
|
:hideNativeControls="true"
|
||||||
:autoplay="true"
|
:autoplay="true"
|
||||||
|
:seekable="filmStore.viewed['FilmOnsiteRandom'] === true"
|
||||||
@ended="handleVideoEnded"
|
@ended="handleVideoEnded"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ const handleQuestionsComplete = () => {
|
|||||||
:controls="true"
|
:controls="true"
|
||||||
:hideNativeControls="true"
|
:hideNativeControls="true"
|
||||||
:autoplay="true"
|
:autoplay="true"
|
||||||
|
:seekable="filmStore.viewed['FilmOnsiteSlider'] === true"
|
||||||
@ended="handleVideoEnded"
|
@ended="handleVideoEnded"
|
||||||
>
|
>
|
||||||
<template #overlay>
|
<template #overlay>
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ const handleVideoEnded = () => {
|
|||||||
:controls="true"
|
:controls="true"
|
||||||
:hideNativeControls="true"
|
:hideNativeControls="true"
|
||||||
:autoplay="true"
|
:autoplay="true"
|
||||||
|
:seekable="filmStore.viewed['FilmOnsiteSlowShutterNight'] === true"
|
||||||
@ended="handleVideoEnded"
|
@ended="handleVideoEnded"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 restoreScrollPage = (): void => {
|
||||||
const savedPage = filmStore.practiceScrollPage
|
const savedPage = filmStore.practiceScrollPage
|
||||||
const targetPage = Math.max(0, Math.min(savedPage, TOTAL_PAGES - 1))
|
const targetPage = Math.max(0, Math.min(savedPage, TOTAL_PAGES - 1))
|
||||||
@@ -251,12 +286,20 @@ const saveScrollPage = (): void => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
restoreScrollPage()
|
restoreScrollPage()
|
||||||
showPageGuide()
|
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(() => {
|
onUnmounted(() => {
|
||||||
saveScrollPage()
|
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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -83,10 +83,18 @@ const handleSaveEvaluation = (items: PracticeRatingItem[]): void => {
|
|||||||
store.setPracticeWorkRating(workKey.value, toScores(items))
|
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 handleVideoEnded = (): void => {
|
||||||
const watchKey = route.query.watchKey
|
if (watchKey.value) {
|
||||||
if (typeof watchKey === 'string' && watchKey) {
|
filmStore.markPracticeVideoWatched(watchKey.value)
|
||||||
filmStore.markPracticeVideoWatched(watchKey)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +126,7 @@ const handleBack = (): void => {
|
|||||||
:muted="false"
|
:muted="false"
|
||||||
:controls="true"
|
:controls="true"
|
||||||
:loop="false"
|
:loop="false"
|
||||||
|
:seekable="isWatched"
|
||||||
@ended="handleVideoEnded"
|
@ended="handleVideoEnded"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,13 @@ const hasUnfinishedLearningVideos = computed(
|
|||||||
completedNavigationSteps.value.some((completed) => !completed),
|
completedNavigationSteps.value.some((completed) => !completed),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const currentVideoWatched = computed(
|
||||||
|
() =>
|
||||||
|
film.professionVideosWatched[
|
||||||
|
professionVideoKeys[currentVideoIndex.value]
|
||||||
|
] === true,
|
||||||
|
)
|
||||||
|
|
||||||
const markCurrentProfessionVideoWatched = () => {
|
const markCurrentProfessionVideoWatched = () => {
|
||||||
const videoKey = professionVideoKeys[currentVideoIndex.value]
|
const videoKey = professionVideoKeys[currentVideoIndex.value]
|
||||||
if (!videoKey) return
|
if (!videoKey) return
|
||||||
@@ -379,6 +386,7 @@ onMounted(() => {
|
|||||||
:volume="1"
|
:volume="1"
|
||||||
:controls="true"
|
:controls="true"
|
||||||
:hideNativeControls="true"
|
:hideNativeControls="true"
|
||||||
|
:seekable="key === 'profession' ? currentVideoWatched : true"
|
||||||
@play="handlePlay"
|
@play="handlePlay"
|
||||||
@ended="onEnded"
|
@ended="onEnded"
|
||||||
>
|
>
|
||||||
@@ -439,7 +447,6 @@ onMounted(() => {
|
|||||||
position-offset-y="85%"
|
position-offset-y="85%"
|
||||||
@step-change="(_: any, index: number) => (guideStepIndex = index)"
|
@step-change="(_: any, index: number) => (guideStepIndex = index)"
|
||||||
@complete="handleProfessionGuideComplete"
|
@complete="handleProfessionGuideComplete"
|
||||||
@skip="handleProfessionGuideComplete"
|
|
||||||
>
|
>
|
||||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_4_004.MP3')">
|
<GuideStepsItem :audio-src="getPublicPath('audio/py_4_004.MP3')">
|
||||||
从刚才的案例可以看出,电影感画面自带一种独特的艺术魅力——或大气恢弘,扑朔迷离或充满故事感。
|
从刚才的案例可以看出,电影感画面自带一种独特的艺术魅力——或大气恢弘,扑朔迷离或充满故事感。
|
||||||
|
|||||||
@@ -254,7 +254,6 @@ function openImages(urls: string[], startIndex = 0) {
|
|||||||
:highlight-radius="20"
|
:highlight-radius="20"
|
||||||
@step-change="handleStepChange"
|
@step-change="handleStepChange"
|
||||||
@complete="handleGuideComplete"
|
@complete="handleGuideComplete"
|
||||||
@skip="handleGuideComplete"
|
|
||||||
position-offset-y="85%"
|
position-offset-y="85%"
|
||||||
>
|
>
|
||||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_4_001.MP3')">
|
<GuideStepsItem :audio-src="getPublicPath('audio/py_4_001.MP3')">
|
||||||
@@ -274,7 +273,6 @@ function openImages(urls: string[], startIndex = 0) {
|
|||||||
:highlight-padding="12"
|
:highlight-padding="12"
|
||||||
:highlight-radius="20"
|
:highlight-radius="20"
|
||||||
@complete="handleReturnGuideComplete"
|
@complete="handleReturnGuideComplete"
|
||||||
@skip="handleReturnGuideComplete"
|
|
||||||
position-offset-y="85%"
|
position-offset-y="85%"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { onMounted } from 'vue'
|
||||||
import { getStaticImage } from '@/utils'
|
import { getStaticImage } from '@/utils'
|
||||||
import { ImgButton } from '@/components'
|
import { ImgButton } from '@/components'
|
||||||
|
|
||||||
@@ -18,6 +19,13 @@ const emit = defineEmits<{
|
|||||||
close: []
|
close: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const certificateDetailSrc = getStaticImage('components/medal/证书详情')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const img = new Image()
|
||||||
|
img.src = certificateDetailSrc
|
||||||
|
})
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
visible.value = false
|
visible.value = false
|
||||||
emit('close')
|
emit('close')
|
||||||
@@ -25,14 +33,7 @@ const handleClose = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Transition
|
<Transition name="certificate-fade">
|
||||||
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]"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
v-if="visible"
|
v-if="visible"
|
||||||
class="absolute inset-0 z-[120] flex items-center justify-center bg-black/45"
|
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">
|
<div class="certificate-detail relative text-2xl">
|
||||||
<img
|
<img
|
||||||
draggable="false"
|
draggable="false"
|
||||||
:src="getStaticImage('components/medal/证书详情')"
|
:src="certificateDetailSrc"
|
||||||
class="certificate-detail-img object-contain"
|
class="certificate-detail-img object-contain"
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
<ImgButton
|
<ImgButton
|
||||||
class="absolute right-2 top-0 z-10"
|
class="absolute right-0 top-0 z-10"
|
||||||
:image="'components/close'"
|
:image="'components/close'"
|
||||||
@click="handleClose"
|
@click="handleClose"
|
||||||
/>
|
/>
|
||||||
@@ -58,22 +59,57 @@ const handleClose = () => {
|
|||||||
<!-- 颁发日期 -->
|
<!-- 颁发日期 -->
|
||||||
<span class="absolute bottom-[122px] left-[390px]">{{ date }}</span>
|
<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 }}
|
{{ studentNumber }}
|
||||||
</span>
|
</span>
|
||||||
<span class="absolute bottom-[380px] left-[1070px]">
|
<span class="absolute bottom-[380px] left-[1070px]">
|
||||||
{{ studentNumber }}
|
{{ studentNumber }}
|
||||||
</span>
|
</span> -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.certificate-detail {
|
||||||
animation: certificate-detail-pop 650ms cubic-bezier(0.18, 0.9, 0.24, 1.12)
|
transform: translateZ(0);
|
||||||
both;
|
will-change: transform, opacity;
|
||||||
filter: drop-shadow(0 0 38px rgba(133, 236, 255, 0.85));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.certificate-detail::before {
|
.certificate-detail::before {
|
||||||
@@ -84,42 +120,14 @@ const handleClose = () => {
|
|||||||
border-radius: 48px;
|
border-radius: 48px;
|
||||||
background: radial-gradient(
|
background: radial-gradient(
|
||||||
circle,
|
circle,
|
||||||
rgba(255, 255, 255, 0.55) 0%,
|
rgba(255, 255, 255, 0.35) 0%,
|
||||||
rgba(117, 235, 255, 0.32) 38%,
|
rgba(117, 235, 255, 0.2) 38%,
|
||||||
rgba(117, 235, 255, 0) 72%
|
rgba(117, 235, 255, 0) 72%
|
||||||
);
|
);
|
||||||
animation: certificate-glow 1600ms ease-in-out infinite;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.certificate-detail-img {
|
.certificate-detail-img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 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>
|
</style>
|
||||||
@@ -125,6 +125,22 @@ const totalProgress = computed(() =>
|
|||||||
filmProgress.value,
|
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 CERTIFICATE_SHOWN_KEY = 'study-progress-certificate-shown'
|
||||||
const certificateVisible = ref(false)
|
const certificateVisible = ref(false)
|
||||||
@@ -140,10 +156,8 @@ const hasAllBadges = computed(() =>
|
|||||||
const hasCertificate = computed(
|
const hasCertificate = computed(
|
||||||
() => basicElements.guideShown[CERTIFICATE_SHOWN_KEY] === true,
|
() => basicElements.guideShown[CERTIFICATE_SHOWN_KEY] === true,
|
||||||
)
|
)
|
||||||
const certificateSrc = computed(() =>
|
const certificateSrc = computed(
|
||||||
getStaticImage(
|
() => `home/stuy/通关证书-${hasCertificate.value ? '已获取' : '暂未获取'}`,
|
||||||
`home/stuy/通关证书-${hasCertificate.value ? '已获取' : '暂未获取'}`,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const buildItem = (
|
const buildItem = (
|
||||||
@@ -222,6 +236,12 @@ const closeCertificate = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openCertificate = () => {
|
||||||
|
if (!hasCertificate.value) return
|
||||||
|
basicElements.ensureCertificate()
|
||||||
|
certificateVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
watch([visible, hasAllBadges, hasCertificate], scheduleCertificatePopup)
|
watch([visible, hasAllBadges, hasCertificate], scheduleCertificatePopup)
|
||||||
|
|
||||||
onBeforeUnmount(clearCertificateTimer)
|
onBeforeUnmount(clearCertificateTimer)
|
||||||
@@ -267,20 +287,39 @@ const certificateInfo = computed(() => basicElements.certificate)
|
|||||||
@click="handleClose"
|
@click="handleClose"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<img
|
<ImgButton
|
||||||
draggable="false"
|
draggable="false"
|
||||||
:src="certificateSrc"
|
:image="certificateSrc"
|
||||||
class="absolute -bottom-12 -right-10 object-contain"
|
class="absolute -bottom-12 -right-10 object-contain"
|
||||||
|
:class="hasCertificate ? 'cursor-pointer' : 'cursor-default'"
|
||||||
alt=""
|
alt=""
|
||||||
|
@click="openCertificate"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="absolute left-0 right-0 top-[150px] flex justify-center">
|
<div class="absolute left-0 right-0 top-[150px] flex justify-center">
|
||||||
<div
|
<div class="flex items-center gap-5">
|
||||||
class="flex items-end pr-10 text-white font-[1000] tracking-widest"
|
<div
|
||||||
:style="{ textShadow: totalTextShadow }"
|
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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -127,10 +127,6 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSkipIntro = () => {
|
|
||||||
showIntroVideo.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Loading 加载状态
|
// Loading 加载状态
|
||||||
const showLoading = ref(isFirstVisit)
|
const showLoading = ref(isFirstVisit)
|
||||||
const loadingProgress = ref(0)
|
const loadingProgress = ref(0)
|
||||||
@@ -251,15 +247,6 @@ export default defineComponent({
|
|||||||
visible={showLoading.value}
|
visible={showLoading.value}
|
||||||
progress={loadingProgress.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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,13 @@ const hasUnfinishedLearningVideos = computed(() =>
|
|||||||
completedNavigationSteps.value.some((completed) => !completed),
|
completedNavigationSteps.value.some((completed) => !completed),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const currentVideoWatched = computed(
|
||||||
|
() =>
|
||||||
|
interview.professionVideosWatched[
|
||||||
|
navigationSteps[currentVideoIndex.value]?.id
|
||||||
|
] === true,
|
||||||
|
)
|
||||||
|
|
||||||
const markCurrentVideoWatched = () => {
|
const markCurrentVideoWatched = () => {
|
||||||
const videoKey = navigationSteps[currentVideoIndex.value]?.id
|
const videoKey = navigationSteps[currentVideoIndex.value]?.id
|
||||||
if (videoKey) interview.markEditingVideoWatched(videoKey)
|
if (videoKey) interview.markEditingVideoWatched(videoKey)
|
||||||
@@ -162,6 +169,7 @@ onBeforeRouteLeave((to) => {
|
|||||||
:volume="1"
|
:volume="1"
|
||||||
:controls="true"
|
:controls="true"
|
||||||
:hideNativeControls="true"
|
:hideNativeControls="true"
|
||||||
|
:seekable="currentVideoWatched"
|
||||||
@ended="playNextVideo"
|
@ended="playNextVideo"
|
||||||
>
|
>
|
||||||
<template #overlay>
|
<template #overlay>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { nextTick, ref } from 'vue'
|
import { computed, nextTick, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ImgButton } from '@/components/Button'
|
import { ImgButton } from '@/components/Button'
|
||||||
import { Video, GuideSteps, GuideStepsItem } from '@/components'
|
import { Video, GuideSteps, GuideStepsItem } from '@/components'
|
||||||
@@ -30,6 +30,10 @@ const handleComplete = () => {
|
|||||||
const handleVideoEnded = () => {
|
const handleVideoEnded = () => {
|
||||||
interviewStore.markViewed('InterviewExportPublish')
|
interviewStore.markViewed('InterviewExportPublish')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentVideoWatched = computed(
|
||||||
|
() => interviewStore.viewed['InterviewExportPublish'] === true,
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -47,6 +51,7 @@ const handleVideoEnded = () => {
|
|||||||
:volume="1"
|
:volume="1"
|
||||||
:controls="true"
|
:controls="true"
|
||||||
:hideNativeControls="true"
|
:hideNativeControls="true"
|
||||||
|
:seekable="currentVideoWatched"
|
||||||
@ended="handleVideoEnded"
|
@ended="handleVideoEnded"
|
||||||
/>
|
/>
|
||||||
<ImgButton
|
<ImgButton
|
||||||
@@ -59,7 +64,6 @@ const handleVideoEnded = () => {
|
|||||||
v-if="showGuide"
|
v-if="showGuide"
|
||||||
position-offset-y="85%"
|
position-offset-y="85%"
|
||||||
@complete="handleComplete"
|
@complete="handleComplete"
|
||||||
@skip="handleComplete"
|
|
||||||
>
|
>
|
||||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_3_013.MP3')">
|
<GuideStepsItem :audio-src="getPublicPath('audio/py_3_013.MP3')">
|
||||||
现在一起来看看导出后的影片效果吧!
|
现在一起来看看导出后的影片效果吧!
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ImgButton } from '@/components/Button'
|
import { ImgButton } from '@/components/Button'
|
||||||
import { Video } from '@/components'
|
import { Video } from '@/components'
|
||||||
@@ -17,6 +17,10 @@ const handleBack = () => {
|
|||||||
const handleEnded = () => {
|
const handleEnded = () => {
|
||||||
interviewStore.markViewed('InterviewSoundEffect')
|
interviewStore.markViewed('InterviewSoundEffect')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentVideoWatched = computed(
|
||||||
|
() => interviewStore.viewed['InterviewSoundEffect'] === true,
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -34,6 +38,7 @@ const handleEnded = () => {
|
|||||||
:volume="1"
|
:volume="1"
|
||||||
:controls="true"
|
:controls="true"
|
||||||
:hideNativeControls="true"
|
:hideNativeControls="true"
|
||||||
|
:seekable="currentVideoWatched"
|
||||||
@ended="handleEnded"
|
@ended="handleEnded"
|
||||||
/>
|
/>
|
||||||
<ImgButton
|
<ImgButton
|
||||||
|
|||||||
@@ -314,7 +314,6 @@ onMounted(() => {
|
|||||||
:steps="returnSteps"
|
:steps="returnSteps"
|
||||||
position-offset-y="85%"
|
position-offset-y="85%"
|
||||||
@complete="handleReturnGuideComplete"
|
@complete="handleReturnGuideComplete"
|
||||||
@skip="handleReturnGuideComplete"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 阶段菜单一次性引导 -->
|
<!-- 阶段菜单一次性引导 -->
|
||||||
@@ -323,7 +322,6 @@ onMounted(() => {
|
|||||||
:steps="stageGuideSteps"
|
:steps="stageGuideSteps"
|
||||||
position-offset-y="85%"
|
position-offset-y="85%"
|
||||||
@complete="handleStageGuideComplete"
|
@complete="handleStageGuideComplete"
|
||||||
@skip="handleStageGuideComplete"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ModuleMedalPopup
|
<ModuleMedalPopup
|
||||||
|
|||||||
@@ -115,6 +115,13 @@ const hasUnfinishedLearningVideos = computed(() =>
|
|||||||
completedNavigationSteps.value.some((completed) => !completed),
|
completedNavigationSteps.value.some((completed) => !completed),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const currentVideoWatched = computed(
|
||||||
|
() =>
|
||||||
|
interview.professionVideosWatched[
|
||||||
|
onsiteVideoKeys[currentVideoIndex.value]
|
||||||
|
] === true,
|
||||||
|
)
|
||||||
|
|
||||||
const markCurrentOnsiteVideoWatched = () => {
|
const markCurrentOnsiteVideoWatched = () => {
|
||||||
const videoKey = onsiteVideoKeys[currentVideoIndex.value]
|
const videoKey = onsiteVideoKeys[currentVideoIndex.value]
|
||||||
if (videoKey) interview.markOnsiteVideoWatched(videoKey)
|
if (videoKey) interview.markOnsiteVideoWatched(videoKey)
|
||||||
@@ -198,6 +205,7 @@ onBeforeRouteLeave((to) => {
|
|||||||
:volume="1"
|
:volume="1"
|
||||||
:controls="true"
|
:controls="true"
|
||||||
:hideNativeControls="true"
|
:hideNativeControls="true"
|
||||||
|
:seekable="currentVideoWatched"
|
||||||
@ended="handleEnded"
|
@ended="handleEnded"
|
||||||
>
|
>
|
||||||
<template #overlay>
|
<template #overlay>
|
||||||
|
|||||||
@@ -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 restoreScrollPage = (): void => {
|
||||||
const savedPage = interviewStore.practiceScrollPage
|
const savedPage = interviewStore.practiceScrollPage
|
||||||
const targetPage = Math.max(0, Math.min(savedPage, TOTAL_PAGES - 1))
|
const targetPage = Math.max(0, Math.min(savedPage, TOTAL_PAGES - 1))
|
||||||
@@ -264,12 +299,20 @@ const saveScrollPage = (): void => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
restoreScrollPage()
|
restoreScrollPage()
|
||||||
showPageGuide()
|
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(() => {
|
onUnmounted(() => {
|
||||||
saveScrollPage()
|
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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ type PracticeRatingScores = Record<string, number>
|
|||||||
|
|
||||||
const workKey = computed<string>(() => decodeURIComponent(props.path))
|
const workKey = computed<string>(() => decodeURIComponent(props.path))
|
||||||
|
|
||||||
|
const watchKey = computed(() => {
|
||||||
|
const key = route.query.watchKey
|
||||||
|
return typeof key === 'string' && key ? key : undefined
|
||||||
|
})
|
||||||
|
|
||||||
const applyScores = (
|
const applyScores = (
|
||||||
items: PracticeRatingItem[],
|
items: PracticeRatingItem[],
|
||||||
scores: PracticeRatingScores,
|
scores: PracticeRatingScores,
|
||||||
@@ -124,6 +129,9 @@ const handleBack = (): void => {
|
|||||||
:muted="false"
|
:muted="false"
|
||||||
:controls="true"
|
:controls="true"
|
||||||
:loop="false"
|
:loop="false"
|
||||||
|
:seekable="
|
||||||
|
watchKey ? store.practiceVideosWatched[watchKey] === true : true
|
||||||
|
"
|
||||||
@ended="handleVideoEnded"
|
@ended="handleVideoEnded"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -255,6 +255,13 @@ const hasUnfinishedLearningVideos = computed(
|
|||||||
completedNavigationSteps.value.some((completed) => !completed),
|
completedNavigationSteps.value.some((completed) => !completed),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const currentVideoWatched = computed(
|
||||||
|
() =>
|
||||||
|
interview.professionVideosWatched[
|
||||||
|
professionVideoKeys[currentVideoIndex.value]
|
||||||
|
] === true,
|
||||||
|
)
|
||||||
|
|
||||||
const markCurrentProfessionVideoWatched = () => {
|
const markCurrentProfessionVideoWatched = () => {
|
||||||
const videoKey = professionVideoKeys[currentVideoIndex.value]
|
const videoKey = professionVideoKeys[currentVideoIndex.value]
|
||||||
if (!videoKey) return
|
if (!videoKey) return
|
||||||
@@ -461,6 +468,7 @@ onBeforeRouteLeave((to) => {
|
|||||||
:volume="1"
|
:volume="1"
|
||||||
:controls="true"
|
:controls="true"
|
||||||
:hideNativeControls="true"
|
:hideNativeControls="true"
|
||||||
|
:seekable="key === 'profession' ? currentVideoWatched : true"
|
||||||
@play="handlePlay"
|
@play="handlePlay"
|
||||||
@ended="onEnded"
|
@ended="onEnded"
|
||||||
>
|
>
|
||||||
@@ -501,7 +509,6 @@ onBeforeRouteLeave((to) => {
|
|||||||
:steps="introGuideSteps"
|
:steps="introGuideSteps"
|
||||||
position-offset-y="85%"
|
position-offset-y="85%"
|
||||||
@complete="handleIntroGuideDone"
|
@complete="handleIntroGuideDone"
|
||||||
@skip="handleIntroGuideDone"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 引导与高亮 -->
|
<!-- 引导与高亮 -->
|
||||||
|
|||||||
@@ -245,7 +245,6 @@ function openImages(urls: string[], startIndex = 0) {
|
|||||||
:highlight-padding="12"
|
:highlight-padding="12"
|
||||||
:highlight-radius="20"
|
:highlight-radius="20"
|
||||||
@complete="handleGuideComplete"
|
@complete="handleGuideComplete"
|
||||||
@skip="handleGuideComplete"
|
|
||||||
position-offset-y="85%"
|
position-offset-y="85%"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -255,7 +254,6 @@ function openImages(urls: string[], startIndex = 0) {
|
|||||||
:highlight-padding="12"
|
:highlight-padding="12"
|
||||||
:highlight-radius="20"
|
:highlight-radius="20"
|
||||||
@complete="handleReturnGuideComplete"
|
@complete="handleReturnGuideComplete"
|
||||||
@skip="handleReturnGuideComplete"
|
|
||||||
position-offset-y="85%"
|
position-offset-y="85%"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -704,7 +704,6 @@ const handleGuideSkip = () => {
|
|||||||
:highlight-radius="16"
|
:highlight-radius="16"
|
||||||
position-offset-y="85%"
|
position-offset-y="85%"
|
||||||
@complete="handleGuideComplete"
|
@complete="handleGuideComplete"
|
||||||
@skip="handleGuideSkip"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -63,6 +63,10 @@ const handlePracticeGuideComplete = () => {
|
|||||||
showPracticeGuide.value = false
|
showPracticeGuide.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentVideoWatched = computed(
|
||||||
|
() => vlogStore.professionVideosWatched[COLOR_VIDEO_KEY] === true,
|
||||||
|
)
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
router.back()
|
router.back()
|
||||||
}
|
}
|
||||||
@@ -90,6 +94,7 @@ const handleBack = () => {
|
|||||||
:volume="1"
|
:volume="1"
|
||||||
:controls="true"
|
:controls="true"
|
||||||
:hideNativeControls="true"
|
:hideNativeControls="true"
|
||||||
|
:seekable="currentVideoWatched"
|
||||||
@ended="handleEnded"
|
@ended="handleEnded"
|
||||||
>
|
>
|
||||||
<template #overlay>
|
<template #overlay>
|
||||||
@@ -129,7 +134,6 @@ const handleBack = () => {
|
|||||||
highlight-selector=".practice-button"
|
highlight-selector=".practice-button"
|
||||||
position-offset-y="80%"
|
position-offset-y="80%"
|
||||||
@complete="handlePracticeGuideComplete"
|
@complete="handlePracticeGuideComplete"
|
||||||
@skip="handlePracticeGuideComplete"
|
|
||||||
>
|
>
|
||||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_2_024.MP3')">
|
<GuideStepsItem :audio-src="getPublicPath('audio/py_2_024.MP3')">
|
||||||
点击【调色实践】,一起给案例中的图片调个色吧!
|
点击【调色实践】,一起给案例中的图片调个色吧!
|
||||||
|
|||||||
@@ -96,6 +96,13 @@ const hasUnfinishedLearningVideos = computed(() =>
|
|||||||
completedNavigationSteps.value.some((completed) => !completed),
|
completedNavigationSteps.value.some((completed) => !completed),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const currentVideoWatched = computed(
|
||||||
|
() =>
|
||||||
|
vlogStore.professionVideosWatched[
|
||||||
|
navigationSteps.value[currentVideoIndex.value]?.id
|
||||||
|
] === true,
|
||||||
|
)
|
||||||
|
|
||||||
const markCurrentVideoWatched = () => {
|
const markCurrentVideoWatched = () => {
|
||||||
const videoKey = navigationSteps.value[currentVideoIndex.value]?.id
|
const videoKey = navigationSteps.value[currentVideoIndex.value]?.id
|
||||||
if (videoKey) vlogStore.markEditingVideoWatched(videoKey)
|
if (videoKey) vlogStore.markEditingVideoWatched(videoKey)
|
||||||
@@ -180,6 +187,7 @@ onBeforeRouteLeave((to) => {
|
|||||||
:volume="1"
|
:volume="1"
|
||||||
:controls="true"
|
:controls="true"
|
||||||
:hideNativeControls="true"
|
:hideNativeControls="true"
|
||||||
|
:seekable="currentVideoWatched"
|
||||||
@ended="handleEnded"
|
@ended="handleEnded"
|
||||||
>
|
>
|
||||||
<template #overlay>
|
<template #overlay>
|
||||||
@@ -244,7 +252,6 @@ onBeforeRouteLeave((to) => {
|
|||||||
v-if="showEndingGuide"
|
v-if="showEndingGuide"
|
||||||
position-offset-y="85%"
|
position-offset-y="85%"
|
||||||
@complete="handleEndingGuideComplete"
|
@complete="handleEndingGuideComplete"
|
||||||
@skip="handleEndingGuideComplete"
|
|
||||||
>
|
>
|
||||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_2_020.MP3')">
|
<GuideStepsItem :audio-src="getPublicPath('audio/py_2_020.MP3')">
|
||||||
当内容结构完成后,我们还可以通过调色与包装,让画面更加统一、更具观感。
|
当内容结构完成后,我们还可以通过调色与包装,让画面更加统一、更具观感。
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { nextTick, ref } from 'vue'
|
import { computed, nextTick, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ImgButton } from '@/components/Button'
|
import { ImgButton } from '@/components/Button'
|
||||||
import { Video, GuideSteps, GuideStepsItem } from '@/components'
|
import { Video, GuideSteps, GuideStepsItem } from '@/components'
|
||||||
@@ -32,6 +32,10 @@ const handleComplete = () => {
|
|||||||
const handleVideoEnded = () => {
|
const handleVideoEnded = () => {
|
||||||
vlogStore.markViewed('VlogExportPublish')
|
vlogStore.markViewed('VlogExportPublish')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentVideoWatched = computed(
|
||||||
|
() => vlogStore.viewed['VlogExportPublish'] === true,
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -50,6 +54,7 @@ const handleVideoEnded = () => {
|
|||||||
:volume="1"
|
:volume="1"
|
||||||
:controls="true"
|
:controls="true"
|
||||||
:hideNativeControls="true"
|
:hideNativeControls="true"
|
||||||
|
:seekable="currentVideoWatched"
|
||||||
@ended="handleVideoEnded"
|
@ended="handleVideoEnded"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -348,7 +348,6 @@ onMounted(() => {
|
|||||||
:steps="returnSteps"
|
:steps="returnSteps"
|
||||||
position-offset-y="85%"
|
position-offset-y="85%"
|
||||||
@complete="handleReturnGuideComplete"
|
@complete="handleReturnGuideComplete"
|
||||||
@skip="handleReturnGuideComplete"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 阶段菜单一次性引导 -->
|
<!-- 阶段菜单一次性引导 -->
|
||||||
@@ -357,7 +356,6 @@ onMounted(() => {
|
|||||||
:steps="stageGuideSteps"
|
:steps="stageGuideSteps"
|
||||||
position-offset-y="85%"
|
position-offset-y="85%"
|
||||||
@complete="handleStageGuideComplete"
|
@complete="handleStageGuideComplete"
|
||||||
@skip="handleStageGuideComplete"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ModuleMedalPopup
|
<ModuleMedalPopup
|
||||||
|
|||||||
@@ -130,6 +130,13 @@ const hasUnfinishedLearningVideos = computed(() =>
|
|||||||
completedNavigationSteps.value.some((completed) => !completed),
|
completedNavigationSteps.value.some((completed) => !completed),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const currentVideoWatched = computed(
|
||||||
|
() =>
|
||||||
|
vlogStore.professionVideosWatched[
|
||||||
|
navigationSteps.value[currentVideoIndex.value]?.id
|
||||||
|
] === true,
|
||||||
|
)
|
||||||
|
|
||||||
const markCurrentVideoWatched = () => {
|
const markCurrentVideoWatched = () => {
|
||||||
const videoKey = navigationSteps.value[currentVideoIndex.value]?.id
|
const videoKey = navigationSteps.value[currentVideoIndex.value]?.id
|
||||||
if (videoKey) vlogStore.markOnsiteVideoWatched(videoKey)
|
if (videoKey) vlogStore.markOnsiteVideoWatched(videoKey)
|
||||||
@@ -211,6 +218,7 @@ onBeforeRouteLeave((to) => {
|
|||||||
:volume="1"
|
:volume="1"
|
||||||
:controls="true"
|
:controls="true"
|
||||||
:hideNativeControls="true"
|
:hideNativeControls="true"
|
||||||
|
:seekable="currentVideoWatched"
|
||||||
:anchors="[]"
|
:anchors="[]"
|
||||||
@ended="handleEnded"
|
@ended="handleEnded"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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 restoreScrollPage = (): void => {
|
||||||
const savedPage = vlogStore.practiceScrollPage
|
const savedPage = vlogStore.practiceScrollPage
|
||||||
const targetPage = Math.max(0, Math.min(savedPage, TOTAL_PAGES - 1))
|
const targetPage = Math.max(0, Math.min(savedPage, TOTAL_PAGES - 1))
|
||||||
@@ -265,12 +300,20 @@ const saveScrollPage = (): void => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
restoreScrollPage()
|
restoreScrollPage()
|
||||||
showPageGuide()
|
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(() => {
|
onUnmounted(() => {
|
||||||
saveScrollPage()
|
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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ type PracticeRatingScores = Record<string, number>
|
|||||||
|
|
||||||
const workKey = computed<string>(() => decodeURIComponent(props.path))
|
const workKey = computed<string>(() => decodeURIComponent(props.path))
|
||||||
|
|
||||||
|
const watchKey = computed(() => {
|
||||||
|
const key = route.query.watchKey
|
||||||
|
return typeof key === 'string' && key ? key : undefined
|
||||||
|
})
|
||||||
|
|
||||||
const applyScores = (
|
const applyScores = (
|
||||||
items: PracticeRatingItem[],
|
items: PracticeRatingItem[],
|
||||||
scores: PracticeRatingScores,
|
scores: PracticeRatingScores,
|
||||||
@@ -125,6 +130,9 @@ const handleBack = (): void => {
|
|||||||
:muted="false"
|
:muted="false"
|
||||||
:controls="true"
|
:controls="true"
|
||||||
:loop="false"
|
:loop="false"
|
||||||
|
:seekable="
|
||||||
|
watchKey ? store.practiceVideosWatched[watchKey] === true : true
|
||||||
|
"
|
||||||
@ended="handleVideoEnded"
|
@ended="handleVideoEnded"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -160,6 +160,13 @@ const hasUnfinishedLearningVideos = computed(
|
|||||||
completedNavigationSteps.value.some((completed) => !completed),
|
completedNavigationSteps.value.some((completed) => !completed),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const currentVideoWatched = computed(
|
||||||
|
() =>
|
||||||
|
vlog.professionVideosWatched[
|
||||||
|
professionVideoKeys[currentVideoIndex.value]
|
||||||
|
] === true,
|
||||||
|
)
|
||||||
|
|
||||||
const markCurrentProfessionVideoWatched = () => {
|
const markCurrentProfessionVideoWatched = () => {
|
||||||
const videoKey = professionVideoKeys[currentVideoIndex.value]
|
const videoKey = professionVideoKeys[currentVideoIndex.value]
|
||||||
if (!videoKey) return
|
if (!videoKey) return
|
||||||
@@ -350,6 +357,7 @@ onBeforeRouteLeave((to) => {
|
|||||||
:volume="1"
|
:volume="1"
|
||||||
:controls="true"
|
:controls="true"
|
||||||
:hideNativeControls="true"
|
:hideNativeControls="true"
|
||||||
|
:seekable="key === 'profession' ? currentVideoWatched : true"
|
||||||
:anchors="[]"
|
:anchors="[]"
|
||||||
@ended="onEnded"
|
@ended="onEnded"
|
||||||
>
|
>
|
||||||
@@ -426,7 +434,6 @@ onBeforeRouteLeave((to) => {
|
|||||||
:steps="introGuideSteps"
|
:steps="introGuideSteps"
|
||||||
position-offset-y="85%"
|
position-offset-y="85%"
|
||||||
@complete="handleIntroGuideDone"
|
@complete="handleIntroGuideDone"
|
||||||
@skip="handleIntroGuideDone"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 引导与高亮 -->
|
<!-- 引导与高亮 -->
|
||||||
|
|||||||
@@ -249,7 +249,6 @@ function openImages(urls: string[], startIndex = 0) {
|
|||||||
:highlight-padding="12"
|
:highlight-padding="12"
|
||||||
:highlight-radius="20"
|
:highlight-radius="20"
|
||||||
@complete="handleGuideComplete"
|
@complete="handleGuideComplete"
|
||||||
@skip="handleGuideComplete"
|
|
||||||
position-offset-y="85%"
|
position-offset-y="85%"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -259,7 +258,6 @@ function openImages(urls: string[], startIndex = 0) {
|
|||||||
:highlight-padding="12"
|
:highlight-padding="12"
|
||||||
:highlight-radius="20"
|
:highlight-radius="20"
|
||||||
@complete="handleReturnGuideComplete"
|
@complete="handleReturnGuideComplete"
|
||||||
@skip="handleReturnGuideComplete"
|
|
||||||
position-offset-y="85%"
|
position-offset-y="85%"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user