feat: 完善访谈与vlog模块页面,重组设备与图片公共资源,新增音频管理与一班对接工具
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
No files matched your search
@@ -12,10 +12,42 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useScale } from '@/composables/useScale'
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
useUserStore,
|
||||
useVlogStore,
|
||||
useFilmStore,
|
||||
useInterviewStore,
|
||||
useBasicElementsStore
|
||||
} from '@/stores'
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
// 使用等比例缩放hook
|
||||
const { scaleStyle } = useScale()
|
||||
const userStore = useUserStore()
|
||||
const vlogStore = useVlogStore()
|
||||
const filmStore = useFilmStore()
|
||||
const interviewStore = useInterviewStore()
|
||||
const basicElementsStore = useBasicElementsStore()
|
||||
|
||||
onMounted(async () => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const courseId = params.get('courseid')
|
||||
const uid = params.get('uid')
|
||||
|
||||
if (courseId || uid) {
|
||||
userStore.setCourseInfo({
|
||||
courseId: courseId ?? null,
|
||||
uid: uid ?? null
|
||||
})
|
||||
}
|
||||
|
||||
await Promise.allSettled([
|
||||
vlogStore.loadVlogStore(),
|
||||
filmStore.loadFilmStore(),
|
||||
interviewStore.loadInterviewStore(),
|
||||
basicElementsStore.loadBasicElementsStore()
|
||||
])
|
||||
})
|
||||
|
||||
// 容器样式
|
||||
const containerStyle = {
|
||||
@@ -25,9 +57,6 @@ const containerStyle = {
|
||||
position: 'relative' as const,
|
||||
background: '#000'
|
||||
}
|
||||
|
||||
// 只缓存技能树页
|
||||
const cachedNames = computed(() => ['SkillPage', 'Work'])
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 272 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 683 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 514 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 322 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 4.6 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 5.9 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 917 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 3.7 MiB |
|
After Width: | Height: | Size: 524 KiB |
|
After Width: | Height: | Size: 6.8 MiB |
@@ -36,11 +36,16 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void
|
||||
}>()
|
||||
|
||||
/** 处理点击事件 */
|
||||
const handleClick = (): void => {
|
||||
if (props.disabled) return
|
||||
if (props.path) {
|
||||
router.push(props.path)
|
||||
}
|
||||
emit('click')
|
||||
}
|
||||
</script>
|
||||
@@ -2,18 +2,18 @@
|
||||
<teleport to="body">
|
||||
<div class="guide-steps-container" v-if="show">
|
||||
<SpotlightOverlay
|
||||
:visible="Boolean(highlightSelector)"
|
||||
:target-selector="highlightSelector"
|
||||
:padding="highlightPadding"
|
||||
:border-radius="highlightRadius"
|
||||
:highlight-mode="highlightMode"
|
||||
:visible="Boolean(currentHighlightSelector)"
|
||||
:target-selector="currentHighlightSelector"
|
||||
:padding="currentHighlightPadding"
|
||||
:border-radius="currentHighlightRadius"
|
||||
:highlight-mode="currentHighlightMode"
|
||||
/>
|
||||
<div class="guide-align" :style="alignStyle">
|
||||
<GuideDragon
|
||||
:show-skip="currentStep < totalSteps"
|
||||
:current-step="currentStep"
|
||||
:total-steps="totalSteps"
|
||||
:position-offset-y="props.positionOffsetY"
|
||||
:position-offset-y="currentPositionOffsetY"
|
||||
@next="handleNext"
|
||||
@skip="handleSkip"
|
||||
@complete="handleComplete"
|
||||
@@ -23,6 +23,7 @@
|
||||
<div v-if="useSlotMode">
|
||||
<component
|
||||
:is="currentStepComponent"
|
||||
:key="currentStepIndex"
|
||||
@continue="handleNext"
|
||||
:is-last-step="currentStep >= totalSteps"
|
||||
:current-step="currentStep"
|
||||
@@ -32,9 +33,14 @@
|
||||
<!-- 兼容原有的配置形式:也包裹 GuideStepsItem,获得继续按钮 -->
|
||||
<GuideStepsItem
|
||||
v-else
|
||||
:key="currentStepIndex"
|
||||
:is-last-step="currentStep >= totalSteps"
|
||||
:current-step="currentStep"
|
||||
:total-steps="totalSteps"
|
||||
:audio-src="currentStepData?.audioSrc"
|
||||
:audio-mode="currentStepData?.audioMode"
|
||||
:audio-channel-id="currentStepData?.audioChannelId"
|
||||
:audio-autoplay="currentStepData?.audioAutoplay"
|
||||
@continue="handleNext"
|
||||
>
|
||||
<!-- 支持组件形式的 content -->
|
||||
@@ -51,18 +57,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, useSlots, onMounted, onUnmounted, reactive } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import type { GuideStep } from './types'
|
||||
import SpotlightOverlay from './SpotlightOverlay.vue'
|
||||
import GuideDragon from './GuideDragon.vue'
|
||||
import GuideStepsItem from './GuideStepsItem.vue'
|
||||
|
||||
interface GuideStep {
|
||||
id: string
|
||||
title?: string
|
||||
content: string | Component
|
||||
nextText?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** 引导步骤配置 - 当使用插槽模式时可以为空 */
|
||||
steps?: GuideStep[]
|
||||
@@ -129,14 +128,11 @@ const alignStyle = reactive<{
|
||||
width: '1920px',
|
||||
height: '1080px'
|
||||
})
|
||||
// 验证并清理选择器,空字符串或只包含空格的选择器视为无效
|
||||
const highlightSelector = computed(() => {
|
||||
if (!props.highlightSelector) return null
|
||||
const trimmed = props.highlightSelector.trim()
|
||||
const normalizeSelector = (selector?: string | null) => {
|
||||
if (!selector) return null
|
||||
const trimmed = selector.trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
})
|
||||
const highlightPadding = computed(() => props.highlightPadding || 0)
|
||||
const highlightRadius = computed(() => props.highlightRadius || 0)
|
||||
}
|
||||
|
||||
const useSlotMode = computed(() => {
|
||||
return slots.default && slots.default().length > 0
|
||||
@@ -161,6 +157,23 @@ const currentStepData = computed(() => {
|
||||
return props.steps[currentStepIndex.value] || null
|
||||
})
|
||||
|
||||
const currentStepConfig = computed(() => props.steps[currentStepIndex.value] || null)
|
||||
const currentHighlightSelector = computed(() => {
|
||||
return normalizeSelector(currentStepConfig.value?.highlightSelector ?? props.highlightSelector)
|
||||
})
|
||||
const currentHighlightPadding = computed(
|
||||
() => currentStepConfig.value?.highlightPadding ?? props.highlightPadding
|
||||
)
|
||||
const currentHighlightRadius = computed(
|
||||
() => currentStepConfig.value?.highlightRadius ?? props.highlightRadius
|
||||
)
|
||||
const currentHighlightMode = computed(
|
||||
() => currentStepConfig.value?.highlightMode ?? props.highlightMode
|
||||
)
|
||||
const currentPositionOffsetY = computed(
|
||||
() => currentStepConfig.value?.positionOffsetY ?? props.positionOffsetY
|
||||
)
|
||||
|
||||
const currentStepComponent = computed(() => {
|
||||
if (!useSlotMode.value) return null
|
||||
return slotSteps.value[currentStepIndex.value] || null
|
||||
|
||||
@@ -5,20 +5,53 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, watch } from 'vue'
|
||||
import { useAudioManager } from '@/composables/useAudioManager'
|
||||
import { IMAGE_PATHS } from '@/utils'
|
||||
import { ImgButton } from '@/components/Button'
|
||||
// 步骤项容器组件,通过插槽传递内容
|
||||
|
||||
type AudioMode = 'global' | 'channel'
|
||||
|
||||
interface Props {
|
||||
isLastStep?: boolean
|
||||
currentStep?: number
|
||||
totalSteps?: number
|
||||
audioSrc?: string
|
||||
audioMode?: AudioMode
|
||||
audioChannelId?: string
|
||||
audioAutoplay?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isLastStep: false,
|
||||
currentStep: 1,
|
||||
totalSteps: 1
|
||||
totalSteps: 1,
|
||||
audioSrc: undefined,
|
||||
audioMode: 'global',
|
||||
audioChannelId: undefined,
|
||||
audioAutoplay: true
|
||||
})
|
||||
|
||||
const { play, stop } = useAudioManager({
|
||||
mode: computed(() => props.audioMode),
|
||||
channelId: computed(() => props.audioChannelId)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.audioSrc, props.audioMode, props.audioChannelId, props.audioAutoplay] as const,
|
||||
([audioSrc]) => {
|
||||
stop()
|
||||
|
||||
if (props.audioAutoplay && audioSrc) {
|
||||
play(audioSrc)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stop()
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
|
||||
@@ -1,98 +1,98 @@
|
||||
<template>
|
||||
<!-- 抽屉遮罩层 -->
|
||||
<div v-if="visible" class="drawer-overlay" @click="handleClose">
|
||||
<!-- 抽屉内容 -->
|
||||
<div
|
||||
class="drawer-container"
|
||||
:class="{
|
||||
'drawer-container--visible': visible && !isClosing,
|
||||
'drawer-container--closing': isClosing
|
||||
}"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 右侧步骤面板 -->
|
||||
<div class="steps-panel">
|
||||
<!-- 关闭按钮 -->
|
||||
<!-- <button class="close-button" @click="handleClose">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M12 4L4 12M4 4L12 12"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button> -->
|
||||
<Transition name="step-navigation" appear>
|
||||
<!-- 抽屉遮罩层 -->
|
||||
<div v-if="visible" class="fixed inset-0 z-50 bg-black/30 backdrop-blur-md">
|
||||
<ImgButton
|
||||
class="absolute right-0 top-0 z-[10]"
|
||||
image="components/close"
|
||||
@click="handleClose"
|
||||
/>
|
||||
|
||||
<!-- 顶部导航箭头 -->
|
||||
<div class="panel-header">
|
||||
<button class="nav-arrow nav-arrow-up" @click="scrollUp" :disabled="!canScrollUp">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<path
|
||||
d="M6 9L3 6L6 3"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 步骤列表 -->
|
||||
<div class="steps-list" ref="stepsListRef">
|
||||
<div
|
||||
v-for="(step, index) in steps"
|
||||
:key="step.id"
|
||||
class="step-item"
|
||||
:class="{ 'step-item--active': index === currentStepIndex }"
|
||||
@click="handleStepClick(index)"
|
||||
>
|
||||
<!-- 播放图标(仅当前步骤显示) -->
|
||||
<!-- <div v-if="index === currentStepIndex" class="play-icon">
|
||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="none">
|
||||
<path d="M1.5 0.5L6.5 4L1.5 7.5V0.5Z" fill="currentColor" />
|
||||
<!-- 抽屉内容 -->
|
||||
<div
|
||||
class="step-navigation-drawer relative flex h-full w-full justify-end"
|
||||
@click="handleClose"
|
||||
>
|
||||
<!-- 右侧步骤面板 -->
|
||||
<div
|
||||
class="relative flex h-full w-[648px] flex-col py-[100px]"
|
||||
:class="'steps-panel-bg'"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 顶部分页箭头(上一页) -->
|
||||
<div class="relative z-10 flex items-center justify-center pointer-events-none">
|
||||
<button
|
||||
class="pointer-events-auto flex h-20 w-20 items-center justify-center border-none bg-transparent transition-all duration-200 disabled:cursor-not-allowed disabled:opacity-40 hover:enabled:scale-110"
|
||||
@click="goPrevPage"
|
||||
:disabled="!canPrevPage"
|
||||
>
|
||||
<svg width="64" height="64" viewBox="0 0 40 40" fill="none">
|
||||
<path d="M5 30L35 30L20 6Z" fill="white" />
|
||||
</svg>
|
||||
</div> -->
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 步骤标签 -->
|
||||
<div class="step-label" :class="getTagColor(step.label)">
|
||||
{{ step.label }}
|
||||
</div>
|
||||
<!-- 步骤列表(分页,每页最多5条) -->
|
||||
<div class="scrollbar-hide z-10 h-[800px] flex-1 px-6 py-4">
|
||||
<div class="h-auto space-y-4">
|
||||
<div
|
||||
v-for="(step, index) in pagedSteps"
|
||||
:key="step.id"
|
||||
class="step-item-bg relative flex h-[122px] w-[831px] cursor-pointer items-center px-8 py-4 transition-all duration-300 hover:-translate-x-[23%]"
|
||||
:class="{
|
||||
'step-item-active-bg h-[230px] w-[1034px] pl-[180px] -translate-x-[23%]':
|
||||
currentPageStartIndex + index === currentStepIndex
|
||||
}"
|
||||
@click="handleStepClick(index)"
|
||||
>
|
||||
<!-- 步骤标签 -->
|
||||
<div
|
||||
class="mr-4 min-w-[5rem] rounded-full px-16 py-4 text-center text-2xl font-semibold shadow-md"
|
||||
:class="getTagColor(step.label)"
|
||||
>
|
||||
{{ step.label }}
|
||||
</div>
|
||||
|
||||
<!-- 步骤内容 -->
|
||||
<div class="step-content">
|
||||
{{ step.content }}
|
||||
</div>
|
||||
<!-- 步骤内容 -->
|
||||
<div class="mr-4 flex-1 text-3xl font-bold leading-[1.5]">
|
||||
{{ step.content }}
|
||||
</div>
|
||||
|
||||
<!-- 步骤数字 -->
|
||||
<div class="step-number" :class="{ 'step-number--active': index === currentStepIndex }">
|
||||
{{ index + 1 }}
|
||||
<!-- 步骤数字(全局序号) -->
|
||||
<div
|
||||
class="absolute right-[15%] z-[1] flex items-center justify-center text-6xl font-semibold text-white/30 transition-all duration-300"
|
||||
:class="{
|
||||
'mb-20 text-[150px] text-white drop-shadow-[5px_10px_5px_rgba(0,0,0,0.1)]':
|
||||
currentPageStartIndex + index === currentStepIndex
|
||||
}"
|
||||
>
|
||||
{{ currentPageStartIndex + index + 1 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部导航 -->
|
||||
<div class="panel-footer">
|
||||
<button class="nav-arrow nav-arrow-down" @click="scrollDown" :disabled="!canScrollDown">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<path
|
||||
d="M6 3L9 6L6 9"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- 底部分页箭头(下一页) -->
|
||||
<div class="relative z-10 flex flex-col items-center justify-center">
|
||||
<button
|
||||
class="flex h-20 w-20 items-center justify-center border-none bg-transparent transition-all duration-200 disabled:cursor-not-allowed disabled:opacity-40 hover:enabled:scale-110"
|
||||
@click="goNextPage"
|
||||
:disabled="!canNextPage"
|
||||
>
|
||||
<svg width="64" height="64" viewBox="0 0 40 40" fill="none">
|
||||
<path d="M5 10L35 10L20 34Z" fill="white" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
||||
import { ImgButton } from '@/components'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { getTagColor } from '@/utils'
|
||||
|
||||
export interface StepItem {
|
||||
@@ -127,294 +127,101 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const stepsListRef = ref<HTMLElement>()
|
||||
const scrollPosition = ref(0)
|
||||
const maxScroll = ref(0)
|
||||
const isClosing = ref(false)
|
||||
// 分页:每页最多 5 条
|
||||
const pageSize = 5
|
||||
const currentPage = ref(0)
|
||||
|
||||
// 计算是否可以滚动
|
||||
const canScrollUp = computed(() => scrollPosition.value > 0)
|
||||
const canScrollDown = computed(() => scrollPosition.value < maxScroll.value)
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil((props.steps?.length ?? 0) / pageSize)))
|
||||
const currentPageStartIndex = computed(() => currentPage.value * pageSize)
|
||||
const pagedSteps = computed(() =>
|
||||
props.steps.slice(currentPageStartIndex.value, currentPageStartIndex.value + pageSize)
|
||||
)
|
||||
|
||||
// 滚动到指定位置
|
||||
const scrollTo = (position: number) => {
|
||||
if (!stepsListRef.value) return
|
||||
stepsListRef.value.scrollTop = position
|
||||
scrollPosition.value = position
|
||||
const canPrevPage = computed(() => currentPage.value > 0)
|
||||
const canNextPage = computed(() => currentPage.value < totalPages.value - 1)
|
||||
|
||||
const goPrevPage = () => {
|
||||
if (!canPrevPage.value) return
|
||||
currentPage.value -= 1
|
||||
}
|
||||
|
||||
// 向上滚动
|
||||
const scrollUp = () => {
|
||||
if (!canScrollUp.value) return
|
||||
const newPosition = Math.max(0, scrollPosition.value - 100)
|
||||
scrollTo(newPosition)
|
||||
}
|
||||
|
||||
// 向下滚动
|
||||
const scrollDown = () => {
|
||||
if (!canScrollDown.value) return
|
||||
const newPosition = Math.min(maxScroll.value, scrollPosition.value + 100)
|
||||
scrollTo(newPosition)
|
||||
const goNextPage = () => {
|
||||
if (!canNextPage.value) return
|
||||
currentPage.value += 1
|
||||
}
|
||||
|
||||
// 处理步骤点击
|
||||
const handleStepClick = (index: number) => {
|
||||
emit('step-click', index, props.steps[index])
|
||||
const globalIndex = currentPageStartIndex.value + index
|
||||
emit('step-click', globalIndex, props.steps[globalIndex])
|
||||
}
|
||||
|
||||
// 处理关闭
|
||||
const handleClose = () => {
|
||||
isClosing.value = true
|
||||
// 延迟关闭,让动画完成
|
||||
setTimeout(() => {
|
||||
emit('close')
|
||||
isClosing.value = false
|
||||
}, 300)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 监听visible变化,重置关闭状态
|
||||
// 当外部当前步骤改变时,自动定位到对应页
|
||||
watch(
|
||||
() => props.visible,
|
||||
() => props.currentStepIndex,
|
||||
newVal => {
|
||||
if (newVal) {
|
||||
isClosing.value = false
|
||||
const idx = newVal ?? 0
|
||||
const targetPage = Math.floor(idx / pageSize)
|
||||
if (targetPage !== currentPage.value) {
|
||||
currentPage.value = Math.min(Math.max(targetPage, 0), totalPages.value - 1)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 更新滚动状态
|
||||
const updateScrollState = () => {
|
||||
if (!stepsListRef.value) return
|
||||
const { scrollTop, scrollHeight, clientHeight } = stepsListRef.value
|
||||
scrollPosition.value = scrollTop
|
||||
maxScroll.value = scrollHeight - clientHeight
|
||||
}
|
||||
|
||||
// 监听滚动事件
|
||||
const handleScroll = () => {
|
||||
updateScrollState()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
updateScrollState()
|
||||
if (stepsListRef.value) {
|
||||
stepsListRef.value.addEventListener('scroll', handleScroll)
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.drawer-overlay {
|
||||
@apply fixed inset-0 z-50;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(8px);
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
/* padding: 100px 0; */
|
||||
.step-navigation-enter-active,
|
||||
.step-navigation-leave-active {
|
||||
transition: opacity 300ms ease;
|
||||
}
|
||||
|
||||
.drawer-container {
|
||||
@apply absolute right-0 top-0 w-1/2 h-full flex justify-end;
|
||||
.step-navigation-enter-from,
|
||||
.step-navigation-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.step-navigation-enter-active .step-navigation-drawer,
|
||||
.step-navigation-leave-active .step-navigation-drawer {
|
||||
transition: transform 300ms ease-out;
|
||||
}
|
||||
|
||||
.step-navigation-enter-from .step-navigation-drawer,
|
||||
.step-navigation-leave-to .step-navigation-drawer {
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.drawer-container--visible {
|
||||
.step-navigation-enter-to .step-navigation-drawer,
|
||||
.step-navigation-leave-from .step-navigation-drawer {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.drawer-container--closing {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.steps-panel {
|
||||
@apply h-full relative;
|
||||
/* background: linear-gradient(
|
||||
180deg,
|
||||
rgba(20, 30, 45, 0.85) 0%,
|
||||
rgba(10, 15, 25, 0.9) 30%,
|
||||
rgba(15, 20, 30, 0.85) 70%,
|
||||
rgba(20, 30, 45, 0.85) 100%
|
||||
); */
|
||||
width: 648px;
|
||||
height: 1080px;
|
||||
.steps-panel-bg {
|
||||
background-image: url('@/assets/images/vlog/专业剖析侧边.png');
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
background-size: cover;
|
||||
backdrop-filter: blur(20px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* border-left: 1px solid rgba(255, 255, 255, 0.1); */
|
||||
/* border-radius: 12px 0 0 12px;
|
||||
box-shadow: -10px 0 30px rgba(0, 0, 0, 0.3); */
|
||||
|
||||
padding: 100px 0;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
@apply absolute top-4 right-4 w-8 h-8 rounded-full flex items-center justify-center;
|
||||
background: rgba(59, 130, 246, 0.9);
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 10;
|
||||
box-shadow: 0 0 20px rgba(59, 130, 246, 0.6);
|
||||
.step-item-bg {
|
||||
background-image: url('@/assets/images/vlog/侧边-底板未选中.png');
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: rgba(59, 130, 246, 1);
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 0 25px rgba(59, 130, 246, 0.8);
|
||||
.step-item-active-bg {
|
||||
background-image: url('@/assets/images/vlog/侧边-选中底板.png');
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
@apply flex justify-center items-center;
|
||||
padding: 3rem 0 1rem 0;
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-arrow {
|
||||
@apply w-6 h-6 flex items-center justify-center;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-arrow:hover:not(:disabled) {
|
||||
color: white;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.nav-arrow:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.steps-list {
|
||||
@apply flex-1 px-6 py-4;
|
||||
.scrollbar-hide {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
z-index: 2;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 60%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.steps-list::-webkit-scrollbar {
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
@apply flex items-center py-4 px-8 mb-4 cursor-pointer transition-all duration-300;
|
||||
position: relative;
|
||||
width: 831px;
|
||||
height: 122px;
|
||||
/* min-height: 4rem; */
|
||||
/* background: rgba(255, 255, 255, 0.05); */
|
||||
background-image: url('@/assets/images/vlog/侧边-底板未选中.png');
|
||||
/* border: 1px solid rgba(255, 255, 255, 0.1); */
|
||||
/* border-radius: 20px; */
|
||||
/* backdrop-filter: blur(10px); */
|
||||
}
|
||||
|
||||
.step-item:hover {
|
||||
/* background: rgba(255, 255, 255, 0.08); */
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.step-item--active {
|
||||
width: 1034px;
|
||||
height: 230px;
|
||||
padding-left: 180px;
|
||||
/* background: linear-gradient(90deg, rgba(59, 130, 246, 0.3) 0%, rgba(59, 130, 246, 0.1) 100%); */
|
||||
/* border-color: rgba(59, 130, 246, 0.6);
|
||||
box-shadow:
|
||||
0 0 30px rgba(59, 130, 246, 0.5),
|
||||
inset 0 0 20px rgba(59, 130, 246, 0.1); */
|
||||
background-image: url('@/assets/images/vlog/侧边-选中底板.png');
|
||||
transform: translateX(-23%);
|
||||
}
|
||||
|
||||
.play-icon {
|
||||
@apply w-5 h-5 flex items-center justify-center mr-3;
|
||||
color: #60a5fa;
|
||||
background: rgba(96, 165, 250, 0.2);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 10px rgba(96, 165, 250, 0.5);
|
||||
}
|
||||
|
||||
.step-label {
|
||||
@apply px-16 py-4 rounded-full text-2xl font-semibold mr-4 whitespace-nowrap;
|
||||
min-width: 5rem;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.step-content {
|
||||
@apply flex-1 text-3xl font-bold;
|
||||
line-height: 1.5;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
@apply flex items-center justify-center text-6xl font-semibold;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
position: absolute;
|
||||
right: 15%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.step-number--active {
|
||||
margin-bottom: 80px;
|
||||
font-size: 150px;
|
||||
color: white;
|
||||
text-shadow: 5px 10px 5px rgba(0, 0, 0, 0.1);
|
||||
/* transform: scale(1.2);
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
box-shadow: 0 0 20px rgba(59, 130, 246, 0.4); */
|
||||
}
|
||||
|
||||
.panel-footer {
|
||||
@apply flex flex-col justify-center items-center;
|
||||
padding: 1rem 0 2rem 0;
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.guide-button {
|
||||
@apply px-8 py-3 rounded-full text-sm font-semibold;
|
||||
background: linear-gradient(135deg, #dc2626, #b91c1c);
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
margin-top: 1rem;
|
||||
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.4);
|
||||
}
|
||||
|
||||
.guide-button:hover {
|
||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 6px 16px rgba(220, 38, 38, 0.5);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,24 @@
|
||||
import type { Component } from 'vue'
|
||||
|
||||
export type AudioMode = 'global' | 'channel'
|
||||
export type HighlightMode = 'border' | 'gradient'
|
||||
|
||||
// 引导步骤类型定义
|
||||
export interface GuideStep {
|
||||
id: string
|
||||
title?: string
|
||||
content: string | Component
|
||||
nextText?: string
|
||||
audioSrc?: string
|
||||
audioMode?: AudioMode
|
||||
audioChannelId?: string
|
||||
audioAutoplay?: boolean
|
||||
highlightSelector?: string | null
|
||||
highlightPadding?: number
|
||||
highlightRadius?: number
|
||||
highlightMode?: HighlightMode
|
||||
/** 垂直偏移(仅Y轴),支持 number(px) 或 '10%'/'120px' */
|
||||
positionOffsetY?: number | string
|
||||
}
|
||||
|
||||
// 引导组件Props类型
|
||||
@@ -23,8 +36,13 @@ export interface GuideStepsProps {
|
||||
autoStart?: boolean
|
||||
keyboardNavigation?: boolean
|
||||
autoPlayInterval?: number
|
||||
highlightSelector?: string | null
|
||||
highlightPadding?: number
|
||||
highlightRadius?: number
|
||||
highlightMode?: HighlightMode
|
||||
/** 垂直偏移(仅Y轴),支持 number(px) 或 '10%'/'120px' */
|
||||
positionOffsetY?: number | string
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
// 引导组件事件类型
|
||||
@@ -45,6 +63,10 @@ export interface GuideStepsItemProps {
|
||||
isLastStep?: boolean
|
||||
currentStep?: number
|
||||
totalSteps?: number
|
||||
audioSrc?: string
|
||||
audioMode?: AudioMode
|
||||
audioChannelId?: string
|
||||
audioAutoplay?: boolean
|
||||
}
|
||||
|
||||
// GuideStepsItem Emits类型
|
||||
|
||||
@@ -51,14 +51,27 @@
|
||||
:src="currentStep === index ? steps[index].nameSelect : steps[index].nameselectNot"
|
||||
alt=""
|
||||
/> -->
|
||||
<div class="flex items-center cursor-pointer" @click="handleStepClick(index + 1)">
|
||||
<div
|
||||
:class="[
|
||||
'flex items-center',
|
||||
isStepClickable(index + 1)
|
||||
? 'cursor-pointer'
|
||||
: 'cursor-not-allowed opacity-45 grayscale'
|
||||
]"
|
||||
@click="handleStepClick(index + 1)"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'font-bold text-4xl ',
|
||||
currentStep === index + 1 ? 'text-[#fbbf24]' : 'text-white'
|
||||
!isStepClickable(index + 1)
|
||||
? 'text-white/60'
|
||||
: currentStep === index + 1
|
||||
? 'text-[#fbbf24]'
|
||||
: 'text-white'
|
||||
]"
|
||||
>{{ step.name }}</span
|
||||
>
|
||||
{{ step.name }}
|
||||
</span>
|
||||
<!-- 图标 -->
|
||||
<img v-if="isStepCompleted(index)" src="@/assets/images/midterm/完成.png" alt="" />
|
||||
<img
|
||||
@@ -79,7 +92,7 @@
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
class="step-ellipsis"
|
||||
:class="['step-ellipsis', !isStepClickable(index + 2) && 'opacity-45 grayscale']"
|
||||
src="@/assets/images/midterm/进度装饰(未完成.png"
|
||||
alt=""
|
||||
/>
|
||||
@@ -174,7 +187,15 @@ const handleComplete = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const isStepClickable = (index: number) => index <= props.completedStep + 1
|
||||
|
||||
const handleStepClick = (index: number) => {
|
||||
// 自由切换规则:
|
||||
// - 已完成的步骤(index <= completedStep)可自由切换
|
||||
// - 下一步(index === completedStep + 1)允许进入学习
|
||||
// - 其他情况(跳关)忽略
|
||||
if (!isStepClickable(index)) return
|
||||
|
||||
currentStep.value = index
|
||||
emit('stepChange', index)
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ const currentIndex = ref(0)
|
||||
const selectedOptionId = ref<string | null>(null) // 单选题选中的选项
|
||||
const selectedOptionIds = ref<string[]>([]) // 多选题选中的选项数组
|
||||
const showErrorExplanation = ref(false) // 是否显示错误解析
|
||||
const showCorrectFeedback = ref(false)
|
||||
|
||||
const total = computed(() => props.questions.length)
|
||||
const currentQuestion = computed(() => props.questions[currentIndex.value])
|
||||
@@ -63,6 +64,7 @@ watch(currentIndex, () => {
|
||||
selectedOptionId.value = null
|
||||
selectedOptionIds.value = []
|
||||
showErrorExplanation.value = false
|
||||
showCorrectFeedback.value = false
|
||||
})
|
||||
|
||||
// 通过文件后缀判断媒体类型
|
||||
@@ -79,6 +81,8 @@ const isImage = (src: string): boolean => {
|
||||
}
|
||||
|
||||
const handleOptionClick = (optionId: string) => {
|
||||
if (showCorrectFeedback.value) return
|
||||
|
||||
if (isMultiple.value) {
|
||||
// 多选题:切换选中状态
|
||||
const index = selectedOptionIds.value.indexOf(optionId)
|
||||
@@ -93,13 +97,16 @@ const handleOptionClick = (optionId: string) => {
|
||||
}
|
||||
// 选择新选项时隐藏错误解析
|
||||
showErrorExplanation.value = false
|
||||
showCorrectFeedback.value = false
|
||||
}
|
||||
|
||||
// 交互规则:选对进入下一步,选错显示解析;最后一题选对触发 complete 事件
|
||||
// 如果 correctOptionId 为空字符串,表示引导题,任意选择都可进入下一题
|
||||
const handleSubmit = () => {
|
||||
const handleSubmit = async () => {
|
||||
const q = currentQuestion.value
|
||||
|
||||
if (showCorrectFeedback.value) return
|
||||
|
||||
if (isMultiple.value) {
|
||||
// 多选题:必须选择至少一个选项
|
||||
if (selectedOptionIds.value.length === 0) return
|
||||
@@ -138,12 +145,15 @@ const handleSubmit = () => {
|
||||
|
||||
// 答对时重置错误解析状态
|
||||
showErrorExplanation.value = false
|
||||
showCorrectFeedback.value = true
|
||||
await new Promise(resolve => setTimeout(resolve, 800))
|
||||
|
||||
if (currentIndex.value < total.value - 1) {
|
||||
currentIndex.value += 1
|
||||
selectedOptionId.value = null
|
||||
selectedOptionIds.value = []
|
||||
} else {
|
||||
showCorrectFeedback.value = false
|
||||
emit('complete')
|
||||
}
|
||||
}
|
||||
@@ -158,9 +168,9 @@ const handleSubmit = () => {
|
||||
<div v-if="showQuestionNumber" class="flex items-center justify-between mb-2">
|
||||
<div class="text-3xl font-semibold">
|
||||
第
|
||||
<span class="text-4xl font-extrabold mx-1 text-emerald-300">{{
|
||||
currentQuestion.id
|
||||
}}</span>
|
||||
<span class="text-4xl font-extrabold mx-1 text-emerald-300">
|
||||
{{ currentQuestion.id }}
|
||||
</span>
|
||||
页
|
||||
<span class="text-2xl opacity-70 ml-2">/ 共 {{ total }} 页</span>
|
||||
</div>
|
||||
@@ -222,6 +232,16 @@ const handleSubmit = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 正确反馈提示 -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="showCorrectFeedback"
|
||||
class="mt-4 p-6 rounded-xl bg-emerald-500/20 border border-emerald-400/30 text-2xl font-semibold text-emerald-100"
|
||||
>
|
||||
回答正确
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 错误解析提示 -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
@@ -255,7 +275,10 @@ const handleSubmit = () => {
|
||||
<div :class="!hasMedia && 'flex justify-center'">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="isMultiple ? selectedOptionIds.length === 0 : !selectedOptionId"
|
||||
:disabled="
|
||||
showCorrectFeedback ||
|
||||
(isMultiple ? selectedOptionIds.length === 0 : !selectedOptionId)
|
||||
"
|
||||
class="inline-flex items-center justify-center px-10 py-3 rounded-full bg-emerald-500 hover:bg-emerald-400 active:bg-emerald-600 text-white text-2xl font-bold shadow-lg shadow-emerald-500/40 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-emerald-500 hover:shadow-xl hover:shadow-emerald-500/50 hover:scale-105 active:scale-100"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
|
||||
@@ -71,6 +71,30 @@ const emit = defineEmits<{
|
||||
(e: 'seeked', currentTime: TimeUnit): void
|
||||
}>()
|
||||
|
||||
interface VideoSlotProps {
|
||||
video: HTMLVideoElement | undefined
|
||||
playing: boolean
|
||||
currentTime: TimeUnit
|
||||
duration: TimeUnit
|
||||
progressPercent: number
|
||||
bufferedPercent: number
|
||||
volume: number
|
||||
rate: number
|
||||
togglePlay: () => Promise<void>
|
||||
seek: (time: number) => void
|
||||
setVolume: (volume: number) => void
|
||||
setRate: (rate: number) => void
|
||||
}
|
||||
|
||||
defineSlots<{
|
||||
default?: () => any
|
||||
anchor?: (props: { anchor: VideoAnchor; resume: () => void; unmute: () => void }) => any
|
||||
overlay?: (props: VideoSlotProps) => any
|
||||
controls?: (props: VideoSlotProps) => any
|
||||
'controls-left'?: (props: VideoSlotProps) => any
|
||||
'controls-right'?: (props: VideoSlotProps) => any
|
||||
}>()
|
||||
|
||||
const videoRef = ref<HTMLVideoElement>()
|
||||
|
||||
// 本地状态
|
||||
@@ -174,6 +198,43 @@ const togglePlay = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// src 切换时的稳健自动播放:
|
||||
// - 如果外部 modelValue 为 true(或当前正在播放),切换资源后会尝试继续播放
|
||||
// - 某些浏览器在切换 src 后立即 play 可能失败(还未 ready / load 过程中),这里做轻量重试
|
||||
const playAfterSourceChange = (el: HTMLVideoElement) => {
|
||||
const shouldPlay = !!props.modelValue || isPlaying.value
|
||||
if (!shouldPlay) return
|
||||
|
||||
// 尝试在下一帧播放;失败则再延迟重试一次
|
||||
requestAnimationFrame(() => {
|
||||
el.play().catch(err1 => {
|
||||
// 再给一次机会:资源 load/seek 状态可能还没就绪
|
||||
window.setTimeout(() => {
|
||||
el.play().catch(err2 => emit('error', err2 ?? err1))
|
||||
}, 50)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.src, props.sources],
|
||||
() => {
|
||||
const el = videoRef.value
|
||||
if (!el) return
|
||||
|
||||
// 切源:重置并重新加载
|
||||
try {
|
||||
el.pause()
|
||||
el.currentTime = 0
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
el.load()
|
||||
playAfterSourceChange(el)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const handleTimeInput = (e: Event) => {
|
||||
if (!videoRef.value) return
|
||||
const target = e.target as HTMLInputElement
|
||||
@@ -466,8 +527,30 @@ const videoStyle = computed(() => ({
|
||||
class="w-8 h-8 flex items-center justify-center rounded hover:bg-white/10"
|
||||
@click.stop="togglePlay"
|
||||
>
|
||||
<span v-if="!isPlaying">▶</span>
|
||||
<span v-else>⏸</span>
|
||||
<!-- 播放图标 -->
|
||||
<svg
|
||||
v-if="!isPlaying"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
class="w-5 h-5"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M7 4.75v14.5c0 .7.76 1.14 1.36.78l9.25-5.5a.9.9 0 0 0 0-1.56l-9.25-5.5A.9.9 0 0 0 7 4.75z"
|
||||
/>
|
||||
</svg>
|
||||
<!-- 暂停图标 -->
|
||||
<svg
|
||||
v-else
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
class="w-5 h-5"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M7 5.25c-.55 0-1 .45-1 1v11.5c0 .55.45 1 1 1h2.5c.55 0 1-.45 1-1V6.25c0-.55-.45-1-1-1H7zm7.5 0c-.55 0-1 .45-1 1v11.5c0 .55.45 1 1 1H17c.55 0 1-.45 1-1V6.25c0-.55-.45-1-1-1h-2.5z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- 左侧扩展位 -->
|
||||
@@ -594,6 +677,7 @@ const videoStyle = computed(() => ({
|
||||
transition: opacity 0.2s var(--sv-ease);
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
.sv-fade-fast-enter-from,
|
||||
.sv-fade-fast-leave-to {
|
||||
opacity: 0;
|
||||
@@ -607,6 +691,7 @@ const videoStyle = computed(() => ({
|
||||
transform 0.25s var(--sv-ease);
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.sv-slide-up-enter-from,
|
||||
.sv-slide-up-leave-to {
|
||||
opacity: 0;
|
||||
|
||||
@@ -1,2 +1,95 @@
|
||||
export { default as Video } from './Video.vue'
|
||||
export type {} from './Video.vue'
|
||||
import type { ComponentOptionsMixin, DefineComponent, PublicProps, SlotsType } from 'vue'
|
||||
import VideoSfc from './Video.vue'
|
||||
|
||||
type TimeUnit = number
|
||||
|
||||
interface VideoSource {
|
||||
src: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
interface VideoAnchor {
|
||||
id: string
|
||||
time: number
|
||||
enabled?: boolean
|
||||
afterResume?: 'hide' | 'remove' | 'keep'
|
||||
meta?: Record<string, any>
|
||||
}
|
||||
|
||||
interface VideoProps {
|
||||
src?: string
|
||||
sources?: VideoSource[]
|
||||
autoplay?: boolean
|
||||
muted?: boolean
|
||||
loop?: boolean
|
||||
preload?: 'auto' | 'metadata' | 'none'
|
||||
poster?: string
|
||||
controls?: boolean
|
||||
hideNativeControls?: boolean
|
||||
autoHideControls?: boolean
|
||||
autoHideDelay?: number
|
||||
showCenterPlayIcon?: boolean
|
||||
modelValue?: boolean
|
||||
volume?: number
|
||||
playbackRate?: number
|
||||
fit?: 'contain' | 'cover' | 'fill'
|
||||
anchors?: VideoAnchor[]
|
||||
}
|
||||
|
||||
interface VideoSlotProps {
|
||||
video: HTMLVideoElement | undefined
|
||||
playing: boolean
|
||||
currentTime: TimeUnit
|
||||
duration: TimeUnit
|
||||
progressPercent: number
|
||||
bufferedPercent: number
|
||||
volume: number
|
||||
rate: number
|
||||
togglePlay: () => Promise<void>
|
||||
seek: (time: number) => void
|
||||
setVolume: (volume: number) => void
|
||||
setRate: (rate: number) => void
|
||||
}
|
||||
|
||||
type VideoSlots = SlotsType<{
|
||||
default?: () => any
|
||||
anchor?: (props: { anchor: VideoAnchor; resume: () => void; unmute: () => void }) => any
|
||||
overlay?: (props: VideoSlotProps) => any
|
||||
controls?: (props: VideoSlotProps) => any
|
||||
'controls-left'?: (props: VideoSlotProps) => any
|
||||
'controls-right'?: (props: VideoSlotProps) => any
|
||||
}>
|
||||
|
||||
type VideoComponent = DefineComponent<
|
||||
VideoProps,
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
ComponentOptionsMixin,
|
||||
ComponentOptionsMixin,
|
||||
{
|
||||
'update:modelValue': (playing: boolean) => void
|
||||
play: () => void
|
||||
pause: () => void
|
||||
ended: () => void
|
||||
timeupdate: (currentTime: TimeUnit, duration: TimeUnit) => void
|
||||
error: (error: unknown) => void
|
||||
loadedmetadata: (duration: TimeUnit) => void
|
||||
ratechange: (rate: number) => void
|
||||
volumechange: (volume: number, muted: boolean) => void
|
||||
seeking: (currentTime: TimeUnit) => void
|
||||
seeked: (currentTime: TimeUnit) => void
|
||||
},
|
||||
string,
|
||||
PublicProps,
|
||||
Readonly<VideoProps>,
|
||||
Record<never, never>,
|
||||
VideoSlots
|
||||
>
|
||||
|
||||
const Video = VideoSfc as VideoComponent
|
||||
|
||||
export { Video }
|
||||
export default Video
|
||||
export type { VideoAnchor, VideoProps, VideoSlotProps }
|
||||
@@ -0,0 +1,145 @@
|
||||
import { computed, onBeforeUnmount, ref, type MaybeRefOrGetter, toValue } from 'vue'
|
||||
|
||||
type AudioMode = 'global' | 'channel'
|
||||
|
||||
interface UseAudioManagerOptions {
|
||||
mode?: MaybeRefOrGetter<AudioMode | undefined>
|
||||
channelId?: MaybeRefOrGetter<string | undefined>
|
||||
}
|
||||
|
||||
interface AudioState {
|
||||
currentSrc: string | null
|
||||
isPlaying: boolean
|
||||
}
|
||||
|
||||
const audioPool = new Map<string, HTMLAudioElement>()
|
||||
const statePool = new Map<string, AudioState>()
|
||||
const playTokens = new Map<string, number>()
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
function resolveKey(options?: UseAudioManagerOptions): string {
|
||||
if ((toValue(options?.mode) ?? 'global') === 'global') {
|
||||
return '__GLOBAL__'
|
||||
}
|
||||
|
||||
return `channel:${toValue(options?.channelId) ?? '__DEFAULT__'}`
|
||||
}
|
||||
|
||||
function getState(key: string): AudioState {
|
||||
return (
|
||||
statePool.get(key) ?? {
|
||||
currentSrc: null,
|
||||
isPlaying: false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function emit(key: string, state: AudioState) {
|
||||
statePool.set(key, state)
|
||||
listeners.forEach(listener => listener())
|
||||
}
|
||||
|
||||
function nextToken(key: string) {
|
||||
const token = (playTokens.get(key) ?? 0) + 1
|
||||
playTokens.set(key, token)
|
||||
return token
|
||||
}
|
||||
|
||||
function isLatestToken(key: string, token: number) {
|
||||
return playTokens.get(key) === token
|
||||
}
|
||||
|
||||
function getOrCreateAudio(key: string): HTMLAudioElement {
|
||||
let audio = audioPool.get(key)
|
||||
|
||||
if (!audio) {
|
||||
audio = new Audio()
|
||||
audioPool.set(key, audio)
|
||||
|
||||
audio.addEventListener('ended', () => {
|
||||
const state = getState(key)
|
||||
|
||||
emit(key, {
|
||||
currentSrc: state.currentSrc,
|
||||
isPlaying: false
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return audio
|
||||
}
|
||||
|
||||
export function useAudioManager(options?: UseAudioManagerOptions) {
|
||||
const key = computed(() => resolveKey(options))
|
||||
const state = ref<AudioState>(getState(key.value))
|
||||
|
||||
const syncState = () => {
|
||||
state.value = getState(key.value)
|
||||
}
|
||||
|
||||
listeners.add(syncState)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
listeners.delete(syncState)
|
||||
})
|
||||
|
||||
const play = async (src: string) => {
|
||||
const currentKey = key.value
|
||||
const audio = getOrCreateAudio(currentKey)
|
||||
const token = nextToken(currentKey)
|
||||
|
||||
audio.pause()
|
||||
|
||||
if (getState(currentKey).currentSrc !== src) {
|
||||
audio.src = src
|
||||
}
|
||||
|
||||
audio.currentTime = 0
|
||||
|
||||
emit(currentKey, {
|
||||
currentSrc: src,
|
||||
isPlaying: false
|
||||
})
|
||||
|
||||
try {
|
||||
await audio.play()
|
||||
|
||||
if (!isLatestToken(currentKey, token)) return
|
||||
|
||||
emit(currentKey, {
|
||||
currentSrc: src,
|
||||
isPlaying: true
|
||||
})
|
||||
} catch {
|
||||
if (!isLatestToken(currentKey, token)) return
|
||||
|
||||
emit(currentKey, {
|
||||
currentSrc: src,
|
||||
isPlaying: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
const currentKey = key.value
|
||||
const audio = audioPool.get(currentKey)
|
||||
|
||||
nextToken(currentKey)
|
||||
|
||||
if (audio) {
|
||||
audio.pause()
|
||||
audio.currentTime = 0
|
||||
}
|
||||
|
||||
emit(currentKey, {
|
||||
currentSrc: getState(currentKey).currentSrc,
|
||||
isPlaying: false
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
play,
|
||||
stop
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export type ModuleName = 'film' | 'vlog' | 'interview'
|
||||
export type StepIndex = 1 | 2 | 3
|
||||
|
||||
/**
|
||||
* 模块步骤 -> 子页面 route.name 列表
|
||||
*
|
||||
* 说明:
|
||||
* - 用于“停留≥阈值后自动计为已查看(viewed)”以及计算 completedStep。
|
||||
* - 按需求:菜单中 disabled=true 的子项不计入完成条件,因此这里不包含 disabled 子项。
|
||||
*/
|
||||
export const moduleStepMap: Record<ModuleName, Record<StepIndex, readonly string[]>> = {
|
||||
film: {
|
||||
1: ['FilmResource', 'FilmCreative', 'FilmScriptWriting', 'FilmStoryboard'],
|
||||
2: ['FilmMidtermEquipment', 'FilmMidtermShooting', 'FilmOnsiteShooting'],
|
||||
3: ['FilmEditing', 'FilmColorPractice', 'FilmSoundEffect', 'FilmExportPublish']
|
||||
},
|
||||
vlog: {
|
||||
1: ['VlogResource', 'VlogCreative', 'VlogScriptWriting', 'VlogStoryboard'],
|
||||
2: ['VlogMidtermEquipment', 'VlogMidtermShooting', 'VlogShooting'],
|
||||
3: ['VlogEditing', 'VlogColor', 'VlogExportPublish']
|
||||
},
|
||||
interview: {
|
||||
1: ['InterviewResource', 'InterviewCreative', 'InterviewScriptWriting', 'InterviewStoryboard'],
|
||||
2: ['InterviewEquipment', 'InterviewShooting', 'InterviewOnsiteShooting'],
|
||||
3: ['InterviewEditing', 'InterviewColorPractice', 'InterviewAudio', 'InterviewExportPublish']
|
||||
}
|
||||
} as const
|
||||
|
||||
export const allTrackedRouteNames = new Set<string>(
|
||||
Object.values(moduleStepMap).flatMap(stepMap => Object.values(stepMap).flatMap(names => names))
|
||||
)
|
||||
|
||||
export function getModuleNameByRouteName(routeName: string): ModuleName | null {
|
||||
for (const moduleName of Object.keys(moduleStepMap) as ModuleName[]) {
|
||||
const stepMap = moduleStepMap[moduleName]
|
||||
for (const step of [1, 2, 3] as const) {
|
||||
if (stepMap[step].includes(routeName)) return moduleName
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { BasicElements, Film, Interview, Vlog } from './modules'
|
||||
import { allTrackedRouteNames, getModuleNameByRouteName } from '@/config/moduleStepMap'
|
||||
import { useFilmStore, useInterviewStore, useVlogStore } from '@/stores'
|
||||
|
||||
const NotFound = {
|
||||
path: '/:pathMatch(.*)*',
|
||||
@@ -15,7 +17,7 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: () => import('@/views/home/index.vue'),
|
||||
component: () => import('@/views/home/index.tsx'),
|
||||
meta: {
|
||||
title: '首页'
|
||||
}
|
||||
@@ -61,13 +63,46 @@ const router = createRouter({
|
||||
routes
|
||||
})
|
||||
|
||||
const VIEW_THRESHOLD_MS = 3000
|
||||
let currentTrackedRouteName: string | null = null
|
||||
let currentEnterAt = 0
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach((to, _from, next) => {
|
||||
router.beforeEach((to, from, next) => {
|
||||
// 设置页面标题
|
||||
if (to.meta?.title) {
|
||||
document.title = `${to.meta.title} - 短视频达人`
|
||||
}
|
||||
|
||||
// 结算离开:from 是上一个页面
|
||||
const fromName = typeof from.name === 'string' ? from.name : null
|
||||
if (
|
||||
fromName &&
|
||||
currentTrackedRouteName === fromName &&
|
||||
allTrackedRouteNames.has(fromName) &&
|
||||
currentEnterAt > 0
|
||||
) {
|
||||
const stayedMs = Date.now() - currentEnterAt
|
||||
if (stayedMs >= VIEW_THRESHOLD_MS) {
|
||||
const moduleName = getModuleNameByRouteName(fromName)
|
||||
if (moduleName === 'film') useFilmStore().markViewed(fromName)
|
||||
if (moduleName === 'vlog') useVlogStore().markViewed(fromName)
|
||||
if (moduleName === 'interview') useInterviewStore().markViewed(fromName)
|
||||
}
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
router.afterEach(to => {
|
||||
// 记录进入:to 是当前页面
|
||||
const toName = typeof to.name === 'string' ? to.name : null
|
||||
if (toName && allTrackedRouteNames.has(toName)) {
|
||||
currentTrackedRouteName = toName
|
||||
currentEnterAt = Date.now()
|
||||
} else {
|
||||
currentTrackedRouteName = null
|
||||
currentEnterAt = 0
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -29,6 +29,22 @@ const Interview: RouteRecordRaw[] = [
|
||||
}
|
||||
},
|
||||
//前期准备
|
||||
{
|
||||
path: '/interview/resource',
|
||||
name: 'InterviewResource',
|
||||
component: () => import('@/views/interview/resource/index.vue'),
|
||||
meta: {
|
||||
title: '资源筹备'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/interview/creative',
|
||||
name: 'InterviewCreative',
|
||||
component: () => import('@/views/interview/creative/index.vue'),
|
||||
meta: {
|
||||
title: '创意构想'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/interview/script-writing',
|
||||
name: 'InterviewScriptWriting',
|
||||
@@ -37,6 +53,14 @@ const Interview: RouteRecordRaw[] = [
|
||||
title: '脚本撰写'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/interview/storyboard',
|
||||
name: 'InterviewStoryboard',
|
||||
component: () => import('@/views/interview/storyboard/index.vue'),
|
||||
meta: {
|
||||
title: '分镜制作'
|
||||
}
|
||||
},
|
||||
//中期拍摄
|
||||
{
|
||||
path: '/interview/equipment',
|
||||
@@ -54,6 +78,14 @@ const Interview: RouteRecordRaw[] = [
|
||||
title: '实地取景'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/interview/on-site-shooting',
|
||||
name: 'InterviewOnsiteShooting',
|
||||
component: () => import('@/views/interview/midterm-shooting/on-site/index.vue'),
|
||||
meta: {
|
||||
title: '现场拍摄'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/interview/filmingLocation-a',
|
||||
name: 'InterviewFilmingLocationA',
|
||||
@@ -89,6 +121,14 @@ const Interview: RouteRecordRaw[] = [
|
||||
// title: '调色'
|
||||
// }
|
||||
// },
|
||||
{
|
||||
path: '/interview/color-practice',
|
||||
name: 'InterviewColorPractice',
|
||||
component: () => import('@/views/interview/after-editing/color-practice.vue'),
|
||||
meta: {
|
||||
title: '后期剪辑-调色实践'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/interview/color',
|
||||
name: 'InterviewColor',
|
||||
@@ -97,6 +137,22 @@ const Interview: RouteRecordRaw[] = [
|
||||
title: '调色'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/interview/audio',
|
||||
name: 'InterviewAudio',
|
||||
component: () => import('@/views/interview/after-editing/audio/index.vue'),
|
||||
meta: {
|
||||
title: '音频'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/interview/export-publish',
|
||||
name: 'InterviewExportPublish',
|
||||
component: () => import('@/views/interview/after-editing/export-publish/index.vue'),
|
||||
meta: {
|
||||
title: '导出发布'
|
||||
}
|
||||
},
|
||||
// 实战操作台
|
||||
{
|
||||
path: '/interview/practice',
|
||||
|
||||
@@ -28,7 +28,23 @@ const Vlog: RouteRecordRaw[] = [
|
||||
title: '发现与记录-视频'
|
||||
}
|
||||
},
|
||||
//前期准备-脚本撰写
|
||||
//前期准备
|
||||
{
|
||||
path: '/vlog/resource',
|
||||
name: 'VlogResource',
|
||||
component: () => import('@/views/vlog/resource/index.vue'),
|
||||
meta: {
|
||||
title: '资源筹备'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/vlog/creative',
|
||||
name: 'VlogCreative',
|
||||
component: () => import('@/views/vlog/creative/index.vue'),
|
||||
meta: {
|
||||
title: '创意构想'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/vlog/script-writing',
|
||||
name: 'VlogScriptWriting',
|
||||
@@ -37,6 +53,14 @@ const Vlog: RouteRecordRaw[] = [
|
||||
title: '脚本撰写'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/vlog/storyboard',
|
||||
name: 'VlogStoryboard',
|
||||
component: () => import('@/views/vlog/storyboard/index.vue'),
|
||||
meta: {
|
||||
title: '分镜制作'
|
||||
}
|
||||
},
|
||||
//中期拍摄
|
||||
{
|
||||
path: '/vlog/equipment',
|
||||
@@ -62,54 +86,54 @@ const Vlog: RouteRecordRaw[] = [
|
||||
title: '现场拍摄'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/vlog/on-site-shooting/time-lapse',
|
||||
name: 'VlogOnsiteTimeLapse',
|
||||
component: () => import('@/views/vlog/midterm-shooting/on-site/time-lapse.vue'),
|
||||
meta: {
|
||||
title: '现场拍摄-延时拍摄'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/vlog/on-site-shooting/slider',
|
||||
name: 'VlogOnsiteSlider',
|
||||
component: () => import('@/views/vlog/midterm-shooting/on-site/slider.vue'),
|
||||
meta: {
|
||||
title: '现场拍摄-滑轨拍摄'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/vlog/on-site-shooting/slow-shutter-day',
|
||||
name: 'VlogOnsiteSlowShutterDay',
|
||||
component: () => import('@/views/vlog/midterm-shooting/on-site/slow-shutter-day.vue'),
|
||||
meta: {
|
||||
title: '现场拍摄-慢门拍摄(白天)'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/vlog/on-site-shooting/slow-shutter-night',
|
||||
name: 'VlogOnsiteSlowShutterNight',
|
||||
component: () => import('@/views/vlog/midterm-shooting/on-site/slow-shutter-night.vue'),
|
||||
meta: {
|
||||
title: '现场拍摄-慢门拍摄(夜晚)'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/vlog/on-site-shooting/low-angle',
|
||||
name: 'VlogOnsiteLowAngle',
|
||||
component: () => import('@/views/vlog/midterm-shooting/on-site/low-angle.vue'),
|
||||
meta: {
|
||||
title: '现场拍摄-低角度拍摄'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/vlog/on-site-shooting/random',
|
||||
name: 'VlogOnsiteRandom',
|
||||
component: () => import('@/views/vlog/midterm-shooting/on-site/random.vue'),
|
||||
meta: {
|
||||
title: '现场拍摄-创作随机性'
|
||||
}
|
||||
},
|
||||
// {
|
||||
// path: '/vlog/on-site-shooting/time-lapse',
|
||||
// name: 'VlogOnsiteTimeLapse',
|
||||
// component: () => import('@/views/vlog/midterm-shooting/on-site/time-lapse.vue'),
|
||||
// meta: {
|
||||
// title: '现场拍摄-延时拍摄'
|
||||
// }
|
||||
// },
|
||||
// {
|
||||
// path: '/vlog/on-site-shooting/slider',
|
||||
// name: 'VlogOnsiteSlider',
|
||||
// component: () => import('@/views/vlog/midterm-shooting/on-site/slider.vue'),
|
||||
// meta: {
|
||||
// title: '现场拍摄-滑轨拍摄'
|
||||
// }
|
||||
// },
|
||||
// {
|
||||
// path: '/vlog/on-site-shooting/slow-shutter-day',
|
||||
// name: 'VlogOnsiteSlowShutterDay',
|
||||
// component: () => import('@/views/vlog/midterm-shooting/on-site/slow-shutter-day.vue'),
|
||||
// meta: {
|
||||
// title: '现场拍摄-慢门拍摄(白天)'
|
||||
// }
|
||||
// },
|
||||
// {
|
||||
// path: '/vlog/on-site-shooting/slow-shutter-night',
|
||||
// name: 'VlogOnsiteSlowShutterNight',
|
||||
// component: () => import('@/views/vlog/midterm-shooting/on-site/slow-shutter-night.vue'),
|
||||
// meta: {
|
||||
// title: '现场拍摄-慢门拍摄(夜晚)'
|
||||
// }
|
||||
// },
|
||||
// {
|
||||
// path: '/vlog/on-site-shooting/low-angle',
|
||||
// name: 'VlogOnsiteLowAngle',
|
||||
// component: () => import('@/views/vlog/midterm-shooting/on-site/low-angle.vue'),
|
||||
// meta: {
|
||||
// title: '现场拍摄-低角度拍摄'
|
||||
// }
|
||||
// },
|
||||
// {
|
||||
// path: '/vlog/on-site-shooting/random',
|
||||
// name: 'VlogOnsiteRandom',
|
||||
// component: () => import('@/views/vlog/midterm-shooting/on-site/random.vue'),
|
||||
// meta: {
|
||||
// title: '现场拍摄-创作随机性'
|
||||
// }
|
||||
// },
|
||||
{
|
||||
path: '/vlog/filmingLocation-a',
|
||||
name: 'VlogFilmingLocationA',
|
||||
@@ -151,6 +175,14 @@ const Vlog: RouteRecordRaw[] = [
|
||||
title: '后期剪辑-调色'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/vlog/export-publish',
|
||||
name: 'VlogExportPublish',
|
||||
component: () => import('@/views/vlog/after-editing/export-publish/index.vue'),
|
||||
meta: {
|
||||
title: '后期剪辑-导出发布'
|
||||
}
|
||||
},
|
||||
// 实战操作台
|
||||
{
|
||||
path: '/vlog/practice',
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const BASIC_ELEMENTS_LESSON_ID = 'xsdh_sdjcys'
|
||||
export const BASIC_ELEMENTS_KEY = 1
|
||||
|
||||
interface BasicElementsSavePayload {
|
||||
isGuideNewUser?: boolean
|
||||
isGuideBasicElementsTask?: boolean
|
||||
isBasicElementsUnlock?: boolean
|
||||
basicElementsUnlockProgress?: number
|
||||
}
|
||||
|
||||
async function getYibanApi(): Promise<typeof import('@/utils/yiban') | null> {
|
||||
try {
|
||||
return await import('@/utils/yiban')
|
||||
} catch (error) {
|
||||
console.error('Failed to load Yiban API', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic Elements store - manages Basic Elements information
|
||||
*/
|
||||
@@ -9,6 +28,8 @@ export const useBasicElementsStore = defineStore(
|
||||
() => {
|
||||
//State
|
||||
|
||||
//action-新手导航(是否引导新手导航)
|
||||
const isGuideNewUser = ref<boolean>(true)
|
||||
//basic-elements-任务取景框引导
|
||||
const isGuideBasicElementsTask = ref<boolean>(true)
|
||||
//basic-elements-是否解锁完成前置(观看视频)
|
||||
@@ -17,24 +38,78 @@ export const useBasicElementsStore = defineStore(
|
||||
const basicElementsUnlockProgress = ref<number>(0)
|
||||
|
||||
//Actions
|
||||
function saveBasicElementsStore(): void {
|
||||
const payload: BasicElementsSavePayload = {
|
||||
isGuideNewUser: isGuideNewUser.value,
|
||||
isGuideBasicElementsTask: isGuideBasicElementsTask.value,
|
||||
isBasicElementsUnlock: isBasicElementsUnlock.value,
|
||||
basicElementsUnlockProgress: basicElementsUnlockProgress.value
|
||||
}
|
||||
|
||||
getYibanApi()
|
||||
.then(api =>
|
||||
api?.setUserDefine(BASIC_ELEMENTS_LESSON_ID, BASIC_ELEMENTS_KEY, JSON.stringify(payload))
|
||||
)
|
||||
.catch(error => console.error('Failed to save Basic Elements store', error))
|
||||
}
|
||||
|
||||
async function loadBasicElementsStore(): Promise<void> {
|
||||
try {
|
||||
const api = await getYibanApi()
|
||||
if (!api) return
|
||||
|
||||
const raw = await api.getUserDefine(BASIC_ELEMENTS_LESSON_ID, BASIC_ELEMENTS_KEY)
|
||||
if (!raw) return
|
||||
|
||||
const data = JSON.parse(raw) as BasicElementsSavePayload
|
||||
isGuideNewUser.value = data.isGuideNewUser ?? true
|
||||
isGuideBasicElementsTask.value = data.isGuideBasicElementsTask ?? true
|
||||
isBasicElementsUnlock.value = data.isBasicElementsUnlock ?? false
|
||||
basicElementsUnlockProgress.value = data.basicElementsUnlockProgress ?? 0
|
||||
} catch (error) {
|
||||
console.error('Failed to load Basic Elements store', error)
|
||||
}
|
||||
}
|
||||
|
||||
function setIsGuideNewUser(value: boolean): void {
|
||||
isGuideNewUser.value = value
|
||||
saveBasicElementsStore()
|
||||
}
|
||||
|
||||
function setIsGuideBasicElementsTask(value: boolean): void {
|
||||
isGuideBasicElementsTask.value = value
|
||||
saveBasicElementsStore()
|
||||
}
|
||||
|
||||
function setIsBasicElementsUnlock(value: boolean): void {
|
||||
isBasicElementsUnlock.value = value
|
||||
saveBasicElementsStore()
|
||||
}
|
||||
|
||||
function setBasicElementsUnlockProgress(value: number): void {
|
||||
basicElementsUnlockProgress.value = value
|
||||
saveBasicElementsStore()
|
||||
}
|
||||
|
||||
function resetBasicElementsState(): void {
|
||||
isGuideNewUser.value = true
|
||||
isGuideBasicElementsTask.value = true
|
||||
isBasicElementsUnlock.value = false
|
||||
basicElementsUnlockProgress.value = 0
|
||||
saveBasicElementsStore()
|
||||
}
|
||||
|
||||
return {
|
||||
//State
|
||||
setIsGuideNewUser,
|
||||
setIsGuideBasicElementsTask,
|
||||
setIsBasicElementsUnlock,
|
||||
setBasicElementsUnlockProgress,
|
||||
saveBasicElementsStore,
|
||||
loadBasicElementsStore,
|
||||
resetBasicElementsState,
|
||||
//Actions
|
||||
isGuideNewUser,
|
||||
isGuideBasicElementsTask,
|
||||
isBasicElementsUnlock,
|
||||
basicElementsUnlockProgress
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { moduleStepMap } from '@/config/moduleStepMap'
|
||||
|
||||
export const FILM_LESSON_ID = 'film_rwqjk'
|
||||
export const FILM_KEY = 4
|
||||
|
||||
interface FilmSavePayload {
|
||||
viewed?: Record<string, boolean>
|
||||
isGuideFilmTask?: boolean
|
||||
hasShownGuideFilmTask?: boolean
|
||||
isFilmUnlock?: boolean
|
||||
FilmUnlockProgress?: number
|
||||
}
|
||||
|
||||
async function getYibanApi(): Promise<typeof import('@/utils/yiban') | null> {
|
||||
try {
|
||||
return await import('@/utils/yiban')
|
||||
} catch (error) {
|
||||
console.error('Failed to load Yiban API', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Film store - manages Film information
|
||||
@@ -9,6 +30,12 @@ export const useFilmStore = defineStore(
|
||||
() => {
|
||||
//State
|
||||
|
||||
/**
|
||||
* 已查看(停留≥阈值)记录
|
||||
* key 推荐用 route.name
|
||||
*/
|
||||
const viewed = ref<Record<string, boolean>>({})
|
||||
|
||||
//Film-任务取景框引导
|
||||
const isGuideFilmTask = ref<boolean>(true)
|
||||
const hasShownGuideFilmTask = ref<boolean>(false)
|
||||
@@ -18,38 +45,116 @@ export const useFilmStore = defineStore(
|
||||
const FilmUnlockProgress = ref<number>(0)
|
||||
|
||||
//Actions
|
||||
function saveFilmStore(): void {
|
||||
const payload: FilmSavePayload = {
|
||||
viewed: viewed.value,
|
||||
isGuideFilmTask: isGuideFilmTask.value,
|
||||
hasShownGuideFilmTask: hasShownGuideFilmTask.value,
|
||||
isFilmUnlock: isFilmUnlock.value,
|
||||
FilmUnlockProgress: FilmUnlockProgress.value
|
||||
}
|
||||
|
||||
getYibanApi()
|
||||
.then(api => api?.setUserDefine(FILM_LESSON_ID, FILM_KEY, JSON.stringify(payload)))
|
||||
.catch(error => console.error('Failed to save Film store', error))
|
||||
}
|
||||
|
||||
async function loadFilmStore(): Promise<void> {
|
||||
try {
|
||||
const api = await getYibanApi()
|
||||
if (!api) return
|
||||
|
||||
const raw = await api.getUserDefine(FILM_LESSON_ID, FILM_KEY)
|
||||
if (!raw) return
|
||||
|
||||
const data = JSON.parse(raw) as FilmSavePayload
|
||||
viewed.value = data.viewed ?? {}
|
||||
isGuideFilmTask.value = data.isGuideFilmTask ?? true
|
||||
hasShownGuideFilmTask.value = data.hasShownGuideFilmTask ?? false
|
||||
isFilmUnlock.value = data.isFilmUnlock ?? false
|
||||
FilmUnlockProgress.value = data.FilmUnlockProgress ?? 0
|
||||
} catch (error) {
|
||||
console.error('Failed to load Film store', error)
|
||||
}
|
||||
}
|
||||
|
||||
function markViewed(key: string): void {
|
||||
if (!key) return
|
||||
viewed.value[key] = true
|
||||
FilmUnlockProgress.value = Math.max(
|
||||
FilmUnlockProgress.value,
|
||||
Math.min(completedStep.value + 1, 4)
|
||||
)
|
||||
saveFilmStore()
|
||||
}
|
||||
|
||||
function setIsGuideFilmTask(value: boolean): void {
|
||||
isGuideFilmTask.value = value
|
||||
saveFilmStore()
|
||||
}
|
||||
|
||||
function setIsFilmUnlock(value: boolean): void {
|
||||
isFilmUnlock.value = value
|
||||
saveFilmStore()
|
||||
}
|
||||
|
||||
function setFilmUnlockProgress(value: number): void {
|
||||
FilmUnlockProgress.value = value
|
||||
const maxUnlockedStep = Math.min(completedStep.value + 1, 4)
|
||||
FilmUnlockProgress.value = Math.min(value, maxUnlockedStep)
|
||||
saveFilmStore()
|
||||
}
|
||||
|
||||
function setHasShownGuideFilmTask(value: boolean): void {
|
||||
hasShownGuideFilmTask.value = value
|
||||
saveFilmStore()
|
||||
}
|
||||
|
||||
// 基于“子页面是否已查看”的大步骤完成度(1..3)
|
||||
// disabled 子项不计入(在这里直接不放进列表)
|
||||
const stepRouteNames = moduleStepMap.film
|
||||
|
||||
const completedStep = computed<number>(() => {
|
||||
const isDone = (step: 1 | 2 | 3) =>
|
||||
stepRouteNames[step].every(name => viewed.value[name] === true)
|
||||
|
||||
if (isDone(1) && isDone(2) && isDone(3)) return 3
|
||||
if (isDone(1) && isDone(2)) return 2
|
||||
if (isDone(1)) return 1
|
||||
return 0
|
||||
})
|
||||
|
||||
// 按子页面粒度的学习进度百分比
|
||||
const progressPercent = computed<number>(() => {
|
||||
const allRoutes = (Object.values(stepRouteNames) as string[][]).flat()
|
||||
if (allRoutes.length === 0) return 0
|
||||
const viewedCount = allRoutes.filter(name => viewed.value[name]).length
|
||||
return Math.round((viewedCount / allRoutes.length) * 100)
|
||||
})
|
||||
|
||||
// Reset all Film-related persistent state
|
||||
function resetFilmState(): void {
|
||||
viewed.value = {}
|
||||
isGuideFilmTask.value = true
|
||||
hasShownGuideFilmTask.value = false
|
||||
isFilmUnlock.value = false
|
||||
FilmUnlockProgress.value = 0
|
||||
saveFilmStore()
|
||||
}
|
||||
|
||||
return {
|
||||
//State
|
||||
markViewed,
|
||||
setIsGuideFilmTask,
|
||||
setIsFilmUnlock,
|
||||
setFilmUnlockProgress,
|
||||
setHasShownGuideFilmTask,
|
||||
saveFilmStore,
|
||||
loadFilmStore,
|
||||
resetFilmState,
|
||||
//Actions
|
||||
completedStep,
|
||||
progressPercent,
|
||||
viewed,
|
||||
isGuideFilmTask,
|
||||
isFilmUnlock,
|
||||
FilmUnlockProgress,
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { moduleStepMap } from '@/config/moduleStepMap'
|
||||
|
||||
export const INTERVIEW_LESSON_ID = 'fangtan_rwqjk'
|
||||
export const INTERVIEW_KEY = 3
|
||||
|
||||
interface InterviewSavePayload {
|
||||
viewed?: Record<string, boolean>
|
||||
isGuideInterviewTask?: boolean
|
||||
isInterviewUnlock?: boolean
|
||||
InterviewUnlockProgress?: number
|
||||
}
|
||||
|
||||
async function getYibanApi(): Promise<typeof import('@/utils/yiban') | null> {
|
||||
try {
|
||||
return await import('@/utils/yiban')
|
||||
} catch (error) {
|
||||
console.error('Failed to load Yiban API', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* I store - manages Interview information
|
||||
@@ -9,6 +29,12 @@ export const useInterviewStore = defineStore(
|
||||
() => {
|
||||
//State
|
||||
|
||||
/**
|
||||
* 已查看(停留≥阈值)记录
|
||||
* key 推荐用 route.name
|
||||
*/
|
||||
const viewed = ref<Record<string, boolean>>({})
|
||||
|
||||
//Interview-任务取景框引导
|
||||
const isGuideInterviewTask = ref<boolean>(true)
|
||||
//Interview-是否解锁完成前置(观看视频)
|
||||
@@ -17,24 +43,107 @@ export const useInterviewStore = defineStore(
|
||||
const InterviewUnlockProgress = ref<number>(0)
|
||||
|
||||
//Actions
|
||||
function saveInterviewStore(): void {
|
||||
const payload: InterviewSavePayload = {
|
||||
viewed: viewed.value,
|
||||
isGuideInterviewTask: isGuideInterviewTask.value,
|
||||
isInterviewUnlock: isInterviewUnlock.value,
|
||||
InterviewUnlockProgress: InterviewUnlockProgress.value
|
||||
}
|
||||
|
||||
getYibanApi()
|
||||
.then(api =>
|
||||
api?.setUserDefine(INTERVIEW_LESSON_ID, INTERVIEW_KEY, JSON.stringify(payload))
|
||||
)
|
||||
.catch(error => console.error('Failed to save Interview store', error))
|
||||
}
|
||||
|
||||
async function loadInterviewStore(): Promise<void> {
|
||||
try {
|
||||
const api = await getYibanApi()
|
||||
if (!api) return
|
||||
|
||||
const raw = await api.getUserDefine(INTERVIEW_LESSON_ID, INTERVIEW_KEY)
|
||||
if (!raw) return
|
||||
|
||||
const data = JSON.parse(raw) as InterviewSavePayload
|
||||
viewed.value = data.viewed ?? {}
|
||||
isGuideInterviewTask.value = data.isGuideInterviewTask ?? true
|
||||
isInterviewUnlock.value = data.isInterviewUnlock ?? false
|
||||
InterviewUnlockProgress.value = data.InterviewUnlockProgress ?? 0
|
||||
} catch (error) {
|
||||
console.error('Failed to load Interview store', error)
|
||||
}
|
||||
}
|
||||
|
||||
function markViewed(key: string): void {
|
||||
if (!key) return
|
||||
viewed.value[key] = true
|
||||
InterviewUnlockProgress.value = Math.max(
|
||||
InterviewUnlockProgress.value,
|
||||
Math.min(completedStep.value + 1, 4)
|
||||
)
|
||||
saveInterviewStore()
|
||||
}
|
||||
|
||||
// 基于“子页面是否已查看”的大步骤完成度(1..3)
|
||||
// disabled 子项不计入(在这里直接不放进列表)
|
||||
const stepRouteNames = moduleStepMap.interview
|
||||
|
||||
const completedStep = computed<number>(() => {
|
||||
const isDone = (step: 1 | 2 | 3) =>
|
||||
stepRouteNames[step].every(name => viewed.value[name] === true)
|
||||
|
||||
if (isDone(1) && isDone(2) && isDone(3)) return 3
|
||||
if (isDone(1) && isDone(2)) return 2
|
||||
if (isDone(1)) return 1
|
||||
return 0
|
||||
})
|
||||
|
||||
const progressPercent = computed<number>(() => {
|
||||
const allRoutes = (Object.values(stepRouteNames) as string[][]).flat()
|
||||
if (allRoutes.length === 0) return 0
|
||||
const viewedCount = allRoutes.filter(name => viewed.value[name]).length
|
||||
return Math.round((viewedCount / allRoutes.length) * 100)
|
||||
})
|
||||
|
||||
function setIsGuideInterviewTask(value: boolean): void {
|
||||
isGuideInterviewTask.value = value
|
||||
saveInterviewStore()
|
||||
}
|
||||
|
||||
function setIsInterviewUnlock(value: boolean): void {
|
||||
isInterviewUnlock.value = value
|
||||
saveInterviewStore()
|
||||
}
|
||||
|
||||
function setInterviewUnlockProgress(value: number): void {
|
||||
InterviewUnlockProgress.value = value
|
||||
const maxUnlockedStep = Math.min(completedStep.value + 1, 4)
|
||||
InterviewUnlockProgress.value = Math.min(value, maxUnlockedStep)
|
||||
saveInterviewStore()
|
||||
}
|
||||
|
||||
function resetInterviewState(): void {
|
||||
viewed.value = {}
|
||||
isGuideInterviewTask.value = true
|
||||
isInterviewUnlock.value = false
|
||||
InterviewUnlockProgress.value = 0
|
||||
saveInterviewStore()
|
||||
}
|
||||
|
||||
return {
|
||||
//State
|
||||
markViewed,
|
||||
setIsGuideInterviewTask,
|
||||
setIsInterviewUnlock,
|
||||
setInterviewUnlockProgress,
|
||||
resetInterviewState,
|
||||
loadInterviewStore,
|
||||
saveInterviewStore,
|
||||
//Actions
|
||||
completedStep,
|
||||
progressPercent,
|
||||
viewed,
|
||||
isGuideInterviewTask,
|
||||
isInterviewUnlock,
|
||||
InterviewUnlockProgress
|
||||
|
||||
@@ -7,22 +7,25 @@ import { ref } from 'vue'
|
||||
export const useUserStore = defineStore(
|
||||
'user',
|
||||
() => {
|
||||
//action-新手导航(是否引导新手导航)
|
||||
const isGuideNewUser = ref<boolean>(true)
|
||||
const courseId = ref<string | null>(null)
|
||||
const uid = ref<string | null>(null)
|
||||
|
||||
function setIsGuideNewUser(value: boolean): void {
|
||||
isGuideNewUser.value = value
|
||||
function setCourseInfo(payload: { courseId: string | null; uid: string | null }): void {
|
||||
courseId.value = payload.courseId
|
||||
uid.value = payload.uid
|
||||
}
|
||||
|
||||
// Reset all User-related persistent state
|
||||
function resetUserState(): void {
|
||||
isGuideNewUser.value = true
|
||||
courseId.value = null
|
||||
uid.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
setIsGuideNewUser,
|
||||
setCourseInfo,
|
||||
resetUserState,
|
||||
isGuideNewUser
|
||||
courseId,
|
||||
uid
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { moduleStepMap } from '@/config/moduleStepMap'
|
||||
|
||||
export const VLOG_LESSON_ID = 'vlog_rwqjk'
|
||||
export const VLOG_KEY = 2
|
||||
|
||||
interface VlogSavePayload {
|
||||
viewed?: Record<string, boolean>
|
||||
isGuideVlogTask?: boolean
|
||||
isGuideVlogReturn?: boolean
|
||||
isGuideVlogStage1?: boolean
|
||||
isGuideVlogStage2?: boolean
|
||||
isGuideVlogStage3?: boolean
|
||||
isVlogUnlock?: boolean
|
||||
vlogUnlockProgress?: number
|
||||
vlogMenuVisible?: boolean
|
||||
}
|
||||
|
||||
async function getYibanApi(): Promise<typeof import('@/utils/yiban') | null> {
|
||||
try {
|
||||
return await import('@/utils/yiban')
|
||||
} catch (error) {
|
||||
console.error('Failed to load Yiban API', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vlog store - manages Vlog information
|
||||
@@ -9,43 +34,185 @@ export const useVlogStore = defineStore(
|
||||
() => {
|
||||
//State
|
||||
|
||||
/**
|
||||
* 已查看(停留≥阈值)记录
|
||||
* key 推荐用 route.name
|
||||
*/
|
||||
const viewed = ref<Record<string, boolean>>({})
|
||||
|
||||
//vlog-任务取景框引导
|
||||
const isGuideVlogTask = ref<boolean>(true)
|
||||
//vlog-返回 VlogWork 时的一次性引导
|
||||
const isGuideVlogReturn = ref<boolean>(true)
|
||||
//vlog-前期准备 菜单一次性引导
|
||||
const isGuideVlogStage1 = ref<boolean>(true)
|
||||
//vlog-中期拍摄 菜单一次性引导
|
||||
const isGuideVlogStage2 = ref<boolean>(true)
|
||||
//vlog-后期剪辑 菜单一次性引导
|
||||
const isGuideVlogStage3 = ref<boolean>(true)
|
||||
//vlog-是否解锁完成前置(观看视频)
|
||||
const isVlogUnlock = ref<boolean>(false)
|
||||
|
||||
//vlog-菜单(Overlay)展开状态
|
||||
const vlogMenuVisible = ref<boolean>(false)
|
||||
|
||||
//vlog-发现与记录解锁进度
|
||||
const vlogUnlockProgress = ref<number>(0)
|
||||
|
||||
//Actions
|
||||
function saveVlogStore(): void {
|
||||
const payload: VlogSavePayload = {
|
||||
viewed: viewed.value,
|
||||
isGuideVlogTask: isGuideVlogTask.value,
|
||||
isGuideVlogReturn: isGuideVlogReturn.value,
|
||||
isGuideVlogStage1: isGuideVlogStage1.value,
|
||||
isGuideVlogStage2: isGuideVlogStage2.value,
|
||||
isGuideVlogStage3: isGuideVlogStage3.value,
|
||||
isVlogUnlock: isVlogUnlock.value,
|
||||
vlogUnlockProgress: vlogUnlockProgress.value,
|
||||
vlogMenuVisible: vlogMenuVisible.value
|
||||
}
|
||||
|
||||
getYibanApi()
|
||||
.then(api => api?.setUserDefine(VLOG_LESSON_ID, VLOG_KEY, JSON.stringify(payload)))
|
||||
.catch(error => console.error('Failed to save Vlog store', error))
|
||||
}
|
||||
|
||||
async function loadVlogStore(): Promise<void> {
|
||||
try {
|
||||
const api = await getYibanApi()
|
||||
if (!api) return
|
||||
|
||||
const raw = await api.getUserDefine(VLOG_LESSON_ID, VLOG_KEY)
|
||||
if (!raw) return
|
||||
|
||||
const data = JSON.parse(raw) as VlogSavePayload
|
||||
viewed.value = data.viewed ?? {}
|
||||
isGuideVlogTask.value = data.isGuideVlogTask ?? true
|
||||
isGuideVlogReturn.value = data.isGuideVlogReturn ?? true
|
||||
isGuideVlogStage1.value = data.isGuideVlogStage1 ?? true
|
||||
isGuideVlogStage2.value = data.isGuideVlogStage2 ?? true
|
||||
isGuideVlogStage3.value = data.isGuideVlogStage3 ?? true
|
||||
isVlogUnlock.value = data.isVlogUnlock ?? false
|
||||
vlogUnlockProgress.value = data.vlogUnlockProgress ?? 0
|
||||
vlogMenuVisible.value = data.vlogMenuVisible ?? false
|
||||
} catch (error) {
|
||||
console.error('Failed to load Vlog store', error)
|
||||
}
|
||||
}
|
||||
|
||||
function markViewed(key: string): void {
|
||||
if (!key) return
|
||||
viewed.value[key] = true
|
||||
vlogUnlockProgress.value = Math.max(
|
||||
vlogUnlockProgress.value,
|
||||
Math.min(completedStep.value + 1, 4)
|
||||
)
|
||||
saveVlogStore()
|
||||
}
|
||||
|
||||
function setIsGuideVlogTask(value: boolean): void {
|
||||
isGuideVlogTask.value = value
|
||||
saveVlogStore()
|
||||
}
|
||||
|
||||
function setIsGuideVlogReturn(value: boolean): void {
|
||||
isGuideVlogReturn.value = value
|
||||
saveVlogStore()
|
||||
}
|
||||
|
||||
function setIsGuideVlogStage1(value: boolean): void {
|
||||
isGuideVlogStage1.value = value
|
||||
saveVlogStore()
|
||||
}
|
||||
|
||||
function setIsGuideVlogStage2(value: boolean): void {
|
||||
isGuideVlogStage2.value = value
|
||||
saveVlogStore()
|
||||
}
|
||||
|
||||
function setIsGuideVlogStage3(value: boolean): void {
|
||||
isGuideVlogStage3.value = value
|
||||
saveVlogStore()
|
||||
}
|
||||
|
||||
function setVlogMenuVisible(value: boolean): void {
|
||||
vlogMenuVisible.value = value
|
||||
saveVlogStore()
|
||||
}
|
||||
|
||||
function setIsVlogUnlock(value: boolean): void {
|
||||
isVlogUnlock.value = value
|
||||
saveVlogStore()
|
||||
}
|
||||
|
||||
function setVlogUnlockProgress(value: number): void {
|
||||
vlogUnlockProgress.value = value
|
||||
const maxUnlockedStep = Math.min(completedStep.value + 1, 4)
|
||||
vlogUnlockProgress.value = Math.min(value, maxUnlockedStep)
|
||||
saveVlogStore()
|
||||
}
|
||||
|
||||
// 基于“子页面是否已查看”的大步骤完成度(1..3)
|
||||
// disabled 子项不计入(在这里直接不放进列表)
|
||||
const stepRouteNames = moduleStepMap.vlog
|
||||
|
||||
const completedStep = computed<number>(() => {
|
||||
const isDone = (step: 1 | 2 | 3) =>
|
||||
stepRouteNames[step].every(name => viewed.value[name] === true)
|
||||
|
||||
if (isDone(1) && isDone(2) && isDone(3)) return 3
|
||||
if (isDone(1) && isDone(2)) return 2
|
||||
if (isDone(1)) return 1
|
||||
return 0
|
||||
})
|
||||
|
||||
// 按子页面粒度的学习进度百分比
|
||||
const progressPercent = computed<number>(() => {
|
||||
const allRoutes = (Object.values(stepRouteNames) as string[][]).flat()
|
||||
if (allRoutes.length === 0) return 0
|
||||
const viewedCount = allRoutes.filter(name => viewed.value[name]).length
|
||||
return Math.round((viewedCount / allRoutes.length) * 100)
|
||||
})
|
||||
|
||||
// Reset all Vlog-related persistent state
|
||||
function resetVlogState(): void {
|
||||
viewed.value = {}
|
||||
isGuideVlogTask.value = true
|
||||
isGuideVlogReturn.value = true
|
||||
isGuideVlogStage1.value = true
|
||||
isGuideVlogStage2.value = true
|
||||
isGuideVlogStage3.value = true
|
||||
isVlogUnlock.value = false
|
||||
vlogUnlockProgress.value = 0
|
||||
vlogMenuVisible.value = false
|
||||
saveVlogStore()
|
||||
}
|
||||
|
||||
return {
|
||||
//State
|
||||
markViewed,
|
||||
setIsGuideVlogTask,
|
||||
setIsGuideVlogReturn,
|
||||
setIsGuideVlogStage1,
|
||||
setIsGuideVlogStage2,
|
||||
setIsGuideVlogStage3,
|
||||
setIsVlogUnlock,
|
||||
setVlogUnlockProgress,
|
||||
setVlogMenuVisible,
|
||||
saveVlogStore,
|
||||
loadVlogStore,
|
||||
resetVlogState,
|
||||
setVlogUnlockProgress,
|
||||
//Actions
|
||||
completedStep,
|
||||
progressPercent,
|
||||
viewed,
|
||||
isGuideVlogTask,
|
||||
isGuideVlogReturn,
|
||||
isGuideVlogStage1,
|
||||
isGuideVlogStage2,
|
||||
isGuideVlogStage3,
|
||||
isVlogUnlock,
|
||||
vlogUnlockProgress
|
||||
vlogUnlockProgress,
|
||||
vlogMenuVisible
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -56,3 +56,12 @@ body {
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus {
|
||||
/* 让背景色变化的过渡时间变得无限长,从而锁定透明状态 */
|
||||
transition: background-color 99999s ease-in-out 0s;
|
||||
/* 记得设置文字颜色,否则可能会变白看不见 */
|
||||
-webkit-text-fill-color: #fff !important;
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useUserStore } from '@/stores'
|
||||
|
||||
declare const $: {
|
||||
extend(value: Record<string, unknown>): void
|
||||
toJSON(value: unknown): string
|
||||
parseJSON(value: string): unknown
|
||||
getUrlVars?: () => string[] & Record<string, string | undefined>
|
||||
getUrlVar?: (name: string) => string | undefined
|
||||
}
|
||||
declare const iscallyb: boolean
|
||||
declare const emykey: string
|
||||
declare function videoAjax(): VideoAjax
|
||||
declare function getFutureDa(days: number): string
|
||||
declare function getCurrentDa(str: string): void
|
||||
declare function formatDateFromMilliseconds(date: Date): string
|
||||
|
||||
interface UserDefineParam {
|
||||
lessonId: string
|
||||
key?: string | number
|
||||
order?: string | number
|
||||
content?: string
|
||||
}
|
||||
|
||||
interface VideoAjaxResponseItem {
|
||||
content?: string
|
||||
}
|
||||
|
||||
interface VideoAjaxResponse {
|
||||
code: number
|
||||
data: {
|
||||
value?: VideoAjaxResponseItem[] | string
|
||||
}
|
||||
}
|
||||
|
||||
interface VideoAjax {
|
||||
setHost(host: string): void
|
||||
addUserDefine(param: UserDefineParam): void
|
||||
getUserDefineInfoByUser(
|
||||
param: UserDefineParam,
|
||||
callback: (data: VideoAjaxResponse | undefined) => void
|
||||
): void
|
||||
getUserDefineInfo(
|
||||
param: UserDefineParam,
|
||||
callback: (data: VideoAjaxResponse | undefined) => void
|
||||
): void
|
||||
finishCourse(param: { order: number; co: string; process: number }): void
|
||||
}
|
||||
|
||||
$.extend({
|
||||
getUrlVars(): string[] & Record<string, string | undefined> {
|
||||
const vars = [] as unknown as string[] & Record<string, string | undefined>
|
||||
const queryIndex = window.location.href.indexOf('?')
|
||||
if (queryIndex === -1) return vars
|
||||
|
||||
const hashes = window.location.href
|
||||
.slice(queryIndex + 1)
|
||||
.split('&')
|
||||
.filter(Boolean)
|
||||
|
||||
for (const item of hashes) {
|
||||
const [keyRaw = '', valueRaw = ''] = item.split('=')
|
||||
const key = decodeURIComponent(keyRaw)
|
||||
const value = decodeURIComponent(valueRaw)
|
||||
vars.push(key)
|
||||
vars[key] = value
|
||||
}
|
||||
|
||||
return vars
|
||||
},
|
||||
getUrlVar(name: string): string | undefined {
|
||||
return $.getUrlVars?.()[name]
|
||||
}
|
||||
})
|
||||
|
||||
const LESSON_PREFIX = 'dsp'
|
||||
|
||||
const videoajax: VideoAjax = videoAjax()
|
||||
videoajax.setHost('/')
|
||||
|
||||
function normalizeLessonId(lessonId: string | number): string {
|
||||
const raw = String(lessonId)
|
||||
return raw.startsWith(`${LESSON_PREFIX}_`) ? raw : `${LESSON_PREFIX}_${raw}`
|
||||
}
|
||||
|
||||
function setLtDefine(key: string | number, value: string): void {
|
||||
if (iscallyb) {
|
||||
setUserDefine(1, key, value)
|
||||
} else {
|
||||
sessionStorage.setItem(`${LESSON_PREFIX}_${key}`, value)
|
||||
}
|
||||
}
|
||||
|
||||
let mycstr9 = ''
|
||||
|
||||
async function getLtDefine(key: string | number): Promise<string> {
|
||||
if (iscallyb) {
|
||||
if (mycstr9 === '') {
|
||||
const all = await getUsersDefine(1, emykey)
|
||||
if (all === undefined || all === null || all === 'null' || all === '') {
|
||||
mycstr9 = getFutureDa(10)
|
||||
setLtDefine(emykey, mycstr9)
|
||||
} else {
|
||||
mycstr9 = all as string
|
||||
}
|
||||
}
|
||||
|
||||
const str = mycstr9 === undefined || mycstr9 === null || mycstr9 === 'null' ? '' : mycstr9
|
||||
getCurrentDa(str)
|
||||
const objitem = await getUserDefine(1, key)
|
||||
return objitem === undefined || objitem === null || objitem === 'null' ? '' : objitem
|
||||
}
|
||||
|
||||
const itemvalue = sessionStorage.getItem(`${LESSON_PREFIX}_${key}`)
|
||||
return itemvalue === undefined || itemvalue === null || itemvalue === 'null' ? '' : itemvalue
|
||||
}
|
||||
|
||||
export function setCourseNotepad(collect: string[]): void {
|
||||
setLtDefine(10002, $.toJSON(collect))
|
||||
}
|
||||
|
||||
export async function getCourseNotepad(): Promise<string[]> {
|
||||
const raw = await getLtDefine(10002)
|
||||
if (raw === undefined || raw === null || raw === '') {
|
||||
return ['', '', '', '', '', '', '', '', '']
|
||||
}
|
||||
|
||||
return $.parseJSON(raw) as string[]
|
||||
}
|
||||
|
||||
export function setUserDefine(
|
||||
lessonId: string | number,
|
||||
key: string | number,
|
||||
value: string
|
||||
): void {
|
||||
const param: UserDefineParam = {
|
||||
lessonId: normalizeLessonId(lessonId),
|
||||
key,
|
||||
content: value
|
||||
}
|
||||
videoajax.addUserDefine(param)
|
||||
}
|
||||
|
||||
function getUserDefineAsyncInternal(
|
||||
lessonId: string | number,
|
||||
key: string | number
|
||||
): Promise<string> {
|
||||
const param: UserDefineParam = {
|
||||
lessonId: normalizeLessonId(lessonId),
|
||||
order: key
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
let content = ''
|
||||
videoajax.getUserDefineInfoByUser(param, res => {
|
||||
console.log(res)
|
||||
if (res && res.code === 200) {
|
||||
const json = (res.data.value || []) as VideoAjaxResponseItem[]
|
||||
content = json[0]?.content || ''
|
||||
} else {
|
||||
content = ''
|
||||
}
|
||||
resolve(content)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function getUserDefine(
|
||||
lessonId: string | number,
|
||||
key: string | number
|
||||
): Promise<string> {
|
||||
return getUserDefineAsyncInternal(lessonId, key)
|
||||
}
|
||||
|
||||
function getUsersDefineAsyncInternal(
|
||||
lessonId: string | number,
|
||||
key: string | number
|
||||
): Promise<string | VideoAjaxResponseItem[]> {
|
||||
const param: UserDefineParam = {
|
||||
lessonId: normalizeLessonId(lessonId),
|
||||
order: key
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
let content: string | VideoAjaxResponseItem[] = ''
|
||||
videoajax.getUserDefineInfo(param, data => {
|
||||
if (data && data.code === 200) {
|
||||
const json = (data.data.value || []) as VideoAjaxResponseItem[]
|
||||
if (json.length > 1) {
|
||||
if (key === emykey) {
|
||||
const mdate = new Date(
|
||||
Math.max.apply(
|
||||
null,
|
||||
json.map(item => new Date(item.content || '').getTime())
|
||||
)
|
||||
)
|
||||
content = formatDateFromMilliseconds(mdate)
|
||||
} else {
|
||||
content = json
|
||||
}
|
||||
} else {
|
||||
content = json[0]?.content || ''
|
||||
}
|
||||
} else {
|
||||
content = ''
|
||||
}
|
||||
resolve(content)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function getUsersDefine(
|
||||
lessonId: string | number,
|
||||
key: string | number
|
||||
): Promise<string | VideoAjaxResponseItem[]> {
|
||||
return getUsersDefineAsyncInternal(lessonId, key)
|
||||
}
|
||||
|
||||
export async function setCourseTimeProgress(order: number): Promise<void> {
|
||||
const courseid = useUserStore().courseId
|
||||
console.info(`courseid:${courseid} order:${order}`)
|
||||
if (iscallyb && courseid !== undefined && courseid !== null && courseid !== '') {
|
||||
if (order > 0) {
|
||||
videoajax.finishCourse({ order, co: courseid, process: 100 })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useRouter } from 'vue-router'
|
||||
import ImgButton from '@/components/Button/ImgButton.vue'
|
||||
import { StepNavigation, GuideSteps, GuideStepsItem } from '@/components/Guide'
|
||||
import { Video, QuestionQuiz, type Question } from '@/components'
|
||||
import { getStaticImage, IMAGE_PATHS } from '@/utils'
|
||||
import { getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
|
||||
// 视频导入
|
||||
// 手机摄像头介绍
|
||||
import video1 from '/video/new/xinshou_shoujishexiangtoujieshao.mp4'
|
||||
@@ -146,7 +146,7 @@ const getQuestionsForVideo = (index: number): Question[] => {
|
||||
return [
|
||||
{
|
||||
id: 3,
|
||||
title: '互动题 4',
|
||||
title: '',
|
||||
description: '(单选)你觉得下面哪句话,更接近“新手拍视频的正确思路”?',
|
||||
options: [
|
||||
{
|
||||
@@ -223,6 +223,12 @@ const handleStepNavigationClose = () => {
|
||||
showStepNavigation.value = false
|
||||
}
|
||||
|
||||
const handleIntroGuideComplete = () => {
|
||||
nextTick(() => {
|
||||
playing.value = true
|
||||
})
|
||||
}
|
||||
|
||||
// 答题完成后的处理
|
||||
const handleQuestionsComplete = () => {
|
||||
showQuiz.value = false
|
||||
@@ -291,12 +297,16 @@ const handleQuestionsComplete = () => {
|
||||
@close="handleStepNavigationClose"
|
||||
/>
|
||||
|
||||
<GuideSteps :visible="true" position-offset-y="85%">
|
||||
<GuideStepsItem>欢迎来到基础拍摄训练区!</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
>接下来,我会带你依次认识手机摄像头、看懂景别、用好慢动作与延时技法。</GuideStepsItem
|
||||
>
|
||||
<GuideStepsItem>还会教你如何拍出更稳的微距和更亮的夜景。</GuideStepsItem>
|
||||
<GuideSteps :visible="true" position-offset-y="85%" @complete="handleIntroGuideComplete">
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_018.MP3')">
|
||||
欢迎来到基础拍摄训练区!
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_019.MP3')">
|
||||
我会带你依次认识手机摄像头、看懂景别、用好慢动作与延时。
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_020.MP3')">
|
||||
还会教你拍出更稳的微距和更亮的夜景。
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRouter } from 'vue-router'
|
||||
import ImgButton from '@/components/Button/ImgButton.vue'
|
||||
import { StepNavigation } from '@/components/Guide'
|
||||
import { Video } from '@/components'
|
||||
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
|
||||
import { getBackgroundImage, getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
|
||||
// 运镜初识 - 暂时用运镜的基本方式代替
|
||||
import video1 from '/video/new/xinshou_yunjingdejibenfangshi.mp4'
|
||||
import video2 from '/video/new/xinshou_zhengquedeshoujipaishe.mp4'
|
||||
@@ -20,6 +20,23 @@ const props = defineProps<{
|
||||
const playing = ref(false)
|
||||
const status = ref(false)
|
||||
|
||||
const showCameraMovementGuide = ref(false)
|
||||
const cameraMovementGuideShown = ref(false)
|
||||
|
||||
const handleIntroGuideComplete = () => {
|
||||
nextTick(() => {
|
||||
playing.value = true
|
||||
})
|
||||
}
|
||||
|
||||
const handleCameraMovementGuideComplete = () => {
|
||||
showCameraMovementGuide.value = false
|
||||
cameraMovementGuideShown.value = true
|
||||
nextTick(() => {
|
||||
playing.value = true
|
||||
})
|
||||
}
|
||||
|
||||
// 步骤导航数据
|
||||
const navigationSteps = ref([
|
||||
// {
|
||||
@@ -60,6 +77,14 @@ const handleVideoChange = () => {
|
||||
status.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// 切到“运镜的基本方式”(index 1)时,先暂停并展示引导对话
|
||||
if (currentVideoIndex.value === 1 && !cameraMovementGuideShown.value) {
|
||||
playing.value = false
|
||||
showCameraMovementGuide.value = true
|
||||
return
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
// 使用受控播放来触发下一个视频自动播放
|
||||
playing.value = true
|
||||
@@ -145,9 +170,26 @@ const handleStepNavigationClose = () => {
|
||||
@close="handleStepNavigationClose"
|
||||
/>
|
||||
|
||||
<GuideSteps :visible="true" position-offset-y="85%">
|
||||
<GuideStepsItem>想拍出好的视频效果,画面稳定性是最基本的要求。</GuideStepsItem>
|
||||
<GuideStepsItem>接下来,我们来学习正确的持机方式和拍摄姿势。</GuideStepsItem>
|
||||
<GuideSteps :visible="true" position-offset-y="85%" @complete="handleIntroGuideComplete">
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_014.MP3')">
|
||||
想拍出好的视频效果,画面稳定性是最基本的要求。
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_015.MP3')">
|
||||
接下来,我们来学习正确的持机方式和拍摄姿势。
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
|
||||
<GuideSteps
|
||||
v-if="showCameraMovementGuide"
|
||||
position-offset-y="85%"
|
||||
@complete="handleCameraMovementGuideComplete"
|
||||
>
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_016.MP3')">
|
||||
当我们将画面稳定之后,就来到了镜头语言。
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_017.MP3')">
|
||||
同一个场景,用不同运镜,表达就不一样。跟着我,去了解5种基础运镜吧~
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRouter } from 'vue-router'
|
||||
import ImgButton from '@/components/Button/ImgButton.vue'
|
||||
import { StepNavigation } from '@/components/Guide'
|
||||
import { Video } from '@/components'
|
||||
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
|
||||
import { getBackgroundImage, getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
|
||||
import video1 from '/video/new/xsdh_rhxzgt_batch.mp4'
|
||||
import video2 from '/video/new/xsdh_jzgtf_batch.mp4'
|
||||
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
|
||||
@@ -75,6 +75,8 @@ const onEnded = () => {
|
||||
const handleStepClick = (index: number) => {
|
||||
currentVideoIndex.value = index
|
||||
currentNavigationStep.value = index
|
||||
// 先重置播放状态,确保 v-model 触发 watcher
|
||||
playing.value = false
|
||||
nextTick(() => {
|
||||
playing.value = true
|
||||
})
|
||||
@@ -84,6 +86,12 @@ const handleStepNavigationClose = () => {
|
||||
showStepNavigation.value = false
|
||||
}
|
||||
|
||||
const handleIntroGuideComplete = () => {
|
||||
nextTick(() => {
|
||||
playing.value = true
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {})
|
||||
</script>
|
||||
|
||||
@@ -145,15 +153,23 @@ onMounted(() => {})
|
||||
:highlight-selector="guideStepIndex === 3 ? '.step-navigation-btn' : ''"
|
||||
position-offset-y="85%"
|
||||
@step-change="(_: any, index: number) => (guideStepIndex = index)"
|
||||
@complete="handleIntroGuideComplete"
|
||||
>
|
||||
<GuideStepsItem>你有没有过这样的感觉?画面里什么都有,却不知道重点在哪里?</GuideStepsItem>
|
||||
<GuideStepsItem>很多时候,不是内容不够好,而是需要有构图思维。</GuideStepsItem>
|
||||
<GuideStepsItem>所谓构图,就是在画面中对元素进行选择、取舍和安排</GuideStepsItem>
|
||||
<GuideStepsItem>
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_005.MP3')">
|
||||
你有没有过这样的感觉?画面里什么都有,却不知道重点在哪里?
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_006.MP3')">
|
||||
很多时候,不是内容不够好,而是需要有构图思维。
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_007.MP3')">
|
||||
所谓构图,就是在画面中对元素进行选择、取舍和安排
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_008.MP3')">
|
||||
<div class="flex flex-wrap items-center justify-start">
|
||||
<div>一起来看看吧~</div>
|
||||
<div class="text-yellow-400">
|
||||
点击<button
|
||||
点击
|
||||
<button
|
||||
class="w-10 h-10 bg-blue-500/80 hover:bg-blue-500 rounded-full inline-flex items-center justify-center text-white transition-all duration-200"
|
||||
title="步骤导航"
|
||||
>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRouter } from 'vue-router'
|
||||
import ImgButton from '@/components/Button/ImgButton.vue'
|
||||
import { StepNavigation } from '@/components/Guide'
|
||||
import { Video, QuestionQuiz, type Question } from '@/components'
|
||||
import { getStaticImage, IMAGE_PATHS } from '@/utils'
|
||||
import { getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
|
||||
import { GuideSteps } from '@/components/Guide'
|
||||
// 光初识 - 暂时用光的基础认知代替
|
||||
import video1 from '/video/new/xinshou_guangdejichurenzhi.mp4'
|
||||
@@ -21,12 +21,25 @@ const showQuiz = ref(false) // 控制是否显示答题窗口
|
||||
|
||||
const isGuide = ref(true)
|
||||
const currentGuideStep = ref(0)
|
||||
const showGoldenHourGuide = ref(false)
|
||||
const goldenHourGuideShown = ref(false)
|
||||
const handleSkip = (index: number) => {
|
||||
currentGuideStep.value = index
|
||||
}
|
||||
|
||||
const handleComplete = () => {
|
||||
isGuide.value = false
|
||||
nextTick(() => {
|
||||
playing.value = true
|
||||
})
|
||||
}
|
||||
|
||||
const handleGoldenHourGuideComplete = () => {
|
||||
showGoldenHourGuide.value = false
|
||||
goldenHourGuideShown.value = true
|
||||
nextTick(() => {
|
||||
playing.value = true
|
||||
})
|
||||
}
|
||||
|
||||
// 步骤导航数据
|
||||
@@ -103,6 +116,14 @@ const handleVideoChange = () => {
|
||||
status.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// 切到“拍摄的黄金时间”(index 2)时,先暂停并展示引导对话
|
||||
if (currentVideoIndex.value === 2 && !goldenHourGuideShown.value) {
|
||||
playing.value = false
|
||||
showGoldenHourGuide.value = true
|
||||
return
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
// 使用受控播放来触发下一个视频自动播放
|
||||
playing.value = true
|
||||
@@ -215,7 +236,28 @@ const handleQuestionsComplete = () => {
|
||||
<!-- 引导与高亮 -->
|
||||
|
||||
<GuideSteps v-if="isGuide" position-offset-y="85%" @complete="handleComplete">
|
||||
<GuideStepsItem>光是摄影的灵魂,摄影高手一定是用光的高手</GuideStepsItem>
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_009.MP3')">
|
||||
光是摄影的灵魂,摄影高手一定是用光的高手
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_010.MP3')">
|
||||
现在, 让我们一起去认识光、读懂光。
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
|
||||
<GuideSteps
|
||||
v-if="showGoldenHourGuide"
|
||||
position-offset-y="85%"
|
||||
@complete="handleGoldenHourGuideComplete"
|
||||
>
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_011.MP3')">
|
||||
同一个地点,不同季节拍出来的感觉为什么完全不一样?
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_012.MP3')">
|
||||
同一条街,早上、下午、傍晚各拍一张,你会发现世界仿佛在悄悄换装。
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_013.MP3')">
|
||||
接下来,我们一起揭开四季与时间的秘密,找一找一年365天中最容易出片的时刻。
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ImgButton from '@/components/Button/ImgButton.vue'
|
||||
import { StepNavigation } from '@/components/Guide'
|
||||
import { StepNavigation, GuideSteps, GuideStepsItem } from '@/components/Guide'
|
||||
import { Video } from '@/components'
|
||||
import { getStaticImage, IMAGE_PATHS } from '@/utils'
|
||||
import video from '/video/new/xsdh_CapCut_Tutorial.mp4'
|
||||
import { getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -18,55 +17,71 @@ const rawSteps = [
|
||||
{
|
||||
id: 'step2',
|
||||
label: '剪映基础操作',
|
||||
content: '认识剪映剪辑界面',
|
||||
content: '初识剪映专业版界面和功能',
|
||||
videoUrl: getPublicPath('video/new/xsdh_CapCut_Tutorial.mp4'),
|
||||
type: 'other' as const
|
||||
},
|
||||
{
|
||||
id: 'step3',
|
||||
label: '剪映基础操作',
|
||||
content: '素材导入及调整',
|
||||
content: '时间线的用法详解',
|
||||
videoUrl: getPublicPath('video/new/xsdh_CapCut_Tutorial.mp4'),
|
||||
type: 'other' as const
|
||||
},
|
||||
{
|
||||
id: 'step4',
|
||||
label: '剪映基础操作',
|
||||
content: '比例及背景设置',
|
||||
content: '转场特效贴纸功能详解',
|
||||
videoUrl: getPublicPath('video/new/xsdh_CapCut_Tutorial.mp4'),
|
||||
type: 'other' as const
|
||||
},
|
||||
{
|
||||
id: 'step5',
|
||||
label: '剪映基础操作',
|
||||
content: '内容裁剪及组合',
|
||||
content: '如何给视频快速上字幕',
|
||||
videoUrl: getPublicPath('video/new/xsdh_CapCut_Tutorial.mp4'),
|
||||
type: 'other' as const
|
||||
},
|
||||
{
|
||||
id: 'step6',
|
||||
label: '剪映基础操作',
|
||||
content: '音频添加及调整',
|
||||
content: '音频的功能应用和处理',
|
||||
videoUrl: getPublicPath('video/new/xsdh_CapCut_Tutorial.mp4'),
|
||||
type: 'other' as const
|
||||
},
|
||||
{
|
||||
id: 'step7',
|
||||
label: '剪映基础操作',
|
||||
content: '添加字幕和贴纸',
|
||||
content: '关键帧功能用法详解',
|
||||
videoUrl: getPublicPath('video/new/xsdh_CapCut_Tutorial.mp4'),
|
||||
type: 'other' as const
|
||||
},
|
||||
{
|
||||
id: 'step8',
|
||||
label: '剪映基础操作',
|
||||
content: '添加特效和滤镜',
|
||||
content: '蒙版功能用法详解',
|
||||
videoUrl: getPublicPath('video/new/xsdh_CapCut_Tutorial.mp4'),
|
||||
type: 'other' as const
|
||||
},
|
||||
{
|
||||
id: 'step9',
|
||||
label: '剪映基础操作',
|
||||
content: '添加转场和动画',
|
||||
content: '混合模式功能用法详解',
|
||||
videoUrl: getPublicPath('video/new/xsdh_CapCut_Tutorial.mp4'),
|
||||
type: 'other' as const
|
||||
},
|
||||
{
|
||||
id: 'step10',
|
||||
label: '剪映基础操作',
|
||||
content: '封面和视频导出',
|
||||
content: '曲线变速功能用法详解',
|
||||
videoUrl: getPublicPath('video/new/xsdh_CapCut_Tutorial.mp4'),
|
||||
type: 'other' as const
|
||||
},
|
||||
{
|
||||
id: 'step11',
|
||||
label: '剪映基础操作',
|
||||
content: '如何给画面调色',
|
||||
videoUrl: getPublicPath('video/new/xsdh_CapCut_Tutorial.mp4'),
|
||||
type: 'other' as const
|
||||
}
|
||||
]
|
||||
@@ -78,13 +93,13 @@ const showStepNavigation = ref(false)
|
||||
const currentNavigationStep = ref(0) // 对应视频索引,从0开始
|
||||
|
||||
// 视频列表
|
||||
const videoList = [video, video, video, video, video, video, video, video, video]
|
||||
const currentVideoIndex = ref(0)
|
||||
|
||||
//按顺序播放
|
||||
const currentVideo = computed(() => videoList[currentVideoIndex.value])
|
||||
const currentVideo = computed(() => rawSteps[currentVideoIndex.value].videoUrl)
|
||||
|
||||
const handleVideoChange = () => {
|
||||
if (currentVideoIndex.value < videoList.length - 1) {
|
||||
if (currentVideoIndex.value < rawSteps.length - 1) {
|
||||
currentVideoIndex.value++
|
||||
currentNavigationStep.value = currentVideoIndex.value
|
||||
} else {
|
||||
@@ -119,6 +134,12 @@ const handleStepClick = (index: number) => {
|
||||
const handleStepNavigationClose = () => {
|
||||
showStepNavigation.value = false
|
||||
}
|
||||
|
||||
const handleIntroGuideComplete = () => {
|
||||
nextTick(() => {
|
||||
playing.value = true
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -174,6 +195,21 @@ const handleStepNavigationClose = () => {
|
||||
@step-click="handleStepClick"
|
||||
@close="handleStepNavigationClose"
|
||||
/>
|
||||
|
||||
<GuideSteps :visible="true" position-offset-y="85%" @complete="handleIntroGuideComplete">
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_021.MP3')">
|
||||
拍摄工作完成以后还需要剪辑来赋予节奏与表达。
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_022.MP3')">
|
||||
接下来,我们一起进入剪映专业版,开启剪映软件的操作学习。
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_023.MP3')">
|
||||
从界面认识到时间线操作,从字幕添加到音频处理,再到调色与特效制作,你将逐步掌握剪映软件的基础技能。
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_024.MP3')">
|
||||
准备好了吗?让零散的素材在你的手中拼接成精彩作品!
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -21,10 +21,10 @@ const back = () => {
|
||||
@click="back"
|
||||
/>
|
||||
<!-- 背景 -->
|
||||
<img class="absolute inset-0" :src="getStaticImage('film/img/背景.png')" alt="" />
|
||||
<img class="absolute inset-0" :src="getStaticImage('img/背景.png')" alt="" />
|
||||
|
||||
<!-- 标题 -->
|
||||
<img class="absolute top-5 z-10" :src="getStaticImage('film/img/调色-标题.png')" alt="" />
|
||||
<img class="absolute top-5 z-10" :src="getStaticImage('img/调色-标题.png')" alt="" />
|
||||
|
||||
<!-- 内容 -->
|
||||
<div class="relative h-full pt-28 overflow-y-scroll">
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ImgButton } from '@/components/Button'
|
||||
import { Video, GuideSteps, GuideStepsItem } from '@/components'
|
||||
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
|
||||
import { useFilmStore } from '@/stores'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const film = useFilmStore()
|
||||
|
||||
const handleBack = () => {
|
||||
router.back()
|
||||
}
|
||||
@@ -25,10 +22,6 @@ const showGuide = ref(true)
|
||||
const handleComplete = () => {
|
||||
showGuide.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
film.setFilmUnlockProgress(4)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -60,7 +53,7 @@ onMounted(() => {
|
||||
/>
|
||||
|
||||
<GuideSteps v-if="showGuide" position-offset-y="85%" @complete="handleComplete">
|
||||
<GuideStepsItem> 现在一起来看看导出后的影片效果吧! </GuideStepsItem>
|
||||
<GuideStepsItem>现在一起来看看导出后的影片效果吧!</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
</div>
|
||||
</template>
|
||||
@@ -21,10 +21,10 @@ const back = () => {
|
||||
@click="back"
|
||||
/>
|
||||
<!-- 背景 -->
|
||||
<img class="absolute inset-0" :src="getStaticImage('film/img/背景.png')" alt="" />
|
||||
<img class="absolute inset-0" :src="getStaticImage('img/背景.png')" alt="" />
|
||||
|
||||
<!-- 标题 -->
|
||||
<img class="absolute top-5 z-10" :src="getStaticImage('film/img/音效-标题.png')" alt="" />
|
||||
<img class="absolute top-5 z-10" :src="getStaticImage('img/音效-标题.png')" alt="" />
|
||||
|
||||
<!-- 内容 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] -->
|
||||
<div class="relative h-full pt-28 overflow-y-scroll">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getStaticImage } from '@/utils'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getBackgroundImage } from '@/utils'
|
||||
import { useFilmStore } from '@/stores'
|
||||
defineOptions({ name: 'FilmMenu' })
|
||||
|
||||
/**
|
||||
@@ -71,12 +72,12 @@ const menuItems = ref([
|
||||
parentValue: 2,
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
label: '素材管理',
|
||||
routeName: 'Film素材管理',
|
||||
parentValue: 3,
|
||||
disabled: true
|
||||
},
|
||||
// {
|
||||
// label: '素材管理',
|
||||
// routeName: 'Film素材管理',
|
||||
// parentValue: 3,
|
||||
// disabled: true
|
||||
// },
|
||||
{
|
||||
label: '剪辑',
|
||||
routeName: 'FilmEditing',
|
||||
@@ -109,6 +110,11 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const film = useFilmStore()
|
||||
|
||||
const isViewed = (routeName: string) => {
|
||||
return film.viewed?.[routeName] === true
|
||||
}
|
||||
|
||||
const currentParentValue = computed<number>(() => {
|
||||
return props.currentStep
|
||||
@@ -131,9 +137,9 @@ const handleItemClick = (item: { routeName: string; disabled: boolean }) => {
|
||||
<div class="absolute left-20 top-0 bottom-0 w-[520px] pt-28" v-if="currentStep">
|
||||
<!-- 分类名称 -->
|
||||
<div class="absolute top-3 left-24 flex items-center space-x-2">
|
||||
<span class="text-4xl text-[#fbbf24] font-black">{{
|
||||
category[currentParentValue - 1].label
|
||||
}}</span>
|
||||
<span class="text-4xl text-[#fbbf24] font-black">
|
||||
{{ category[currentParentValue - 1].label }}
|
||||
</span>
|
||||
<img class="filter-blue" :src="category[currentParentValue - 1].icon" alt="" />
|
||||
</div>
|
||||
<!-- 背景图片 -->
|
||||
@@ -157,7 +163,18 @@ const handleItemClick = (item: { routeName: string; disabled: boolean }) => {
|
||||
class="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
||||
:style="{ backgroundImage: getBackgroundImage('components/menu-hover.png') }"
|
||||
></div>
|
||||
<span :class="[!item.disabled ? 'menu-glow-text' : '']">{{ item.label }}</span>
|
||||
<span class="flex items-center gap-2" :class="[!item.disabled ? 'menu-glow-text' : '']">
|
||||
<span>{{ item.label }}</span>
|
||||
<img
|
||||
class="w-10"
|
||||
:src="
|
||||
isViewed(item.routeName)
|
||||
? '/src/assets/images/midterm/完成.png'
|
||||
: '/src/assets/images/midterm/未完成.png'
|
||||
"
|
||||
alt=""
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -32,10 +32,10 @@ const handleOpenImages = () => {
|
||||
@click="back"
|
||||
/>
|
||||
<!-- 背景 -->
|
||||
<img class="absolute inset-0" :src="getStaticImage('film/img/背景.png')" alt="" />
|
||||
<img class="absolute inset-0" :src="getStaticImage('img/背景.png')" alt="" />
|
||||
|
||||
<!-- 标题 -->
|
||||
<img class="absolute top-5 z-10" :src="getStaticImage('film/img/创意构想-标题.png')" alt="" />
|
||||
<img class="absolute top-5 z-10" :src="getStaticImage('img/创意构想-标题.png')" alt="" />
|
||||
|
||||
<!-- 内容 -->
|
||||
<div class="relative h-full pt-28 overflow-y-scroll">
|
||||
|
||||
@@ -17,9 +17,7 @@ const status = ref(true)
|
||||
|
||||
const currentStep = computed(() => film.FilmUnlockProgress)
|
||||
|
||||
const progress = computed(() => {
|
||||
return Math.round((currentStep.value / 4) * 100)
|
||||
})
|
||||
const progress = computed(() => film.progressPercent)
|
||||
|
||||
const guideIndex = ref<number>(0)
|
||||
const showTaskGuide = computed(
|
||||
@@ -130,7 +128,7 @@ watch(
|
||||
highlight-selector=".right-section"
|
||||
position-offset-y="50%"
|
||||
>
|
||||
<GuideStepsItem> 点击这里进入实战操作台 </GuideStepsItem>
|
||||
<GuideStepsItem>点击这里进入实战操作台</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
|
||||
<!-- 引导任务 -->
|
||||
@@ -167,7 +165,7 @@ watch(
|
||||
<ProgressBar
|
||||
module-name="film"
|
||||
:current-step="currentStep"
|
||||
:completed-step="2"
|
||||
:completed-step="film.completedStep"
|
||||
@step-change="handleStepChange"
|
||||
/>
|
||||
</div>
|
||||
@@ -177,6 +175,7 @@ watch(
|
||||
<ProgressBar
|
||||
module-name="film"
|
||||
:current-step="currentStep"
|
||||
:completed-step="film.completedStep"
|
||||
@complete="handleComplete"
|
||||
@step-change="handleStepChange"
|
||||
/>
|
||||
@@ -191,11 +190,9 @@ watch(
|
||||
>
|
||||
<GuideStepsItem>
|
||||
<div class="flex items-center justify-start">
|
||||
请点击<img
|
||||
class="w-20"
|
||||
:src="getStaticImage('film/icon')"
|
||||
alt=""
|
||||
/>进入任务取景框开始学习!
|
||||
请点击
|
||||
<img class="w-20" :src="getStaticImage('film/icon')" alt="" />
|
||||
进入任务取景框开始学习!
|
||||
</div>
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="tsx" setup>
|
||||
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
|
||||
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
|
||||
import { GuideSteps } from '@/components/Guide'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ImgButton } from '@/components/Button'
|
||||
@@ -28,42 +28,100 @@ const steps = ref([
|
||||
highlightSelector: '.plate'
|
||||
}
|
||||
])
|
||||
const stepIndex = ref<number>(0)
|
||||
const hiddenbar = ref(false)
|
||||
const currentIndex = ref(0) // 当前显示的图片索引
|
||||
const selectedMenuIndex = ref(0) // 当前选中的菜单项索引
|
||||
|
||||
const currentHighlight = computed(() => {
|
||||
return steps.value[stepIndex.value ?? 0]?.highlightSelector
|
||||
})
|
||||
type EquipmentCategoryId = 'camera' | 'slider' | 'tripod' | 'stabilizer' | 'light' | 'filter'
|
||||
interface EquipmentProduct {
|
||||
img: string
|
||||
}
|
||||
interface EquipmentCategory {
|
||||
id: EquipmentCategoryId
|
||||
name: string
|
||||
disabled: boolean
|
||||
titleImg: string
|
||||
contentImg: string
|
||||
products: EquipmentProduct[]
|
||||
}
|
||||
|
||||
// 菜单项数据
|
||||
const menuItems = ref([
|
||||
{ id: 'phone', name: '相机/摄像机/微单', disabled: false },
|
||||
{ id: 'phone-stand', name: '滑轨', disabled: true },
|
||||
{ id: 'bluetooth-remote', name: '三脚架', disabled: true },
|
||||
{ id: 'stabilizer', name: '稳定器', disabled: true },
|
||||
{ id: 'props', name: '灯光', disabled: true },
|
||||
{ id: 'phone-lens', name: '滤镜', disabled: true }
|
||||
// 菜单项数据 + 对应资源(资源都在 equipment)
|
||||
const categories = ref<EquipmentCategory[]>([
|
||||
{
|
||||
id: 'camera',
|
||||
name: '相机/摄像机/微单',
|
||||
disabled: false,
|
||||
titleImg: getStaticImage('equipment/相机标题.png'),
|
||||
contentImg: getStaticImage('equipment/相机内容.png'),
|
||||
products: [
|
||||
{ img: getStaticImage('equipment/相机产品.png') },
|
||||
{ img: getStaticImage('equipment/摄像机产品.png') },
|
||||
{ img: getStaticImage('equipment/微单产品.png') }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'slider',
|
||||
name: '滑轨',
|
||||
disabled: false,
|
||||
titleImg: getStaticImage('equipment/滑轨标题.png'),
|
||||
contentImg: getStaticImage('equipment/滑轨内容.png'),
|
||||
products: [
|
||||
{ img: getStaticImage('equipment/便携滑轨产品.png') },
|
||||
{ img: getStaticImage('equipment/脚架式长滑轨产品.png') }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'tripod',
|
||||
name: '三脚架',
|
||||
disabled: false,
|
||||
titleImg: getStaticImage('equipment/三脚架标题.png'),
|
||||
contentImg: getStaticImage('equipment/三脚架内容.png'),
|
||||
products: [{ img: getStaticImage('equipment/三角架产品.png') }]
|
||||
},
|
||||
{
|
||||
id: 'stabilizer',
|
||||
name: '稳定器',
|
||||
disabled: false,
|
||||
titleImg: getStaticImage('equipment/稳定器标题.png'),
|
||||
contentImg: getStaticImage('equipment/稳定器内容.png'),
|
||||
products: [
|
||||
{ img: getStaticImage('equipment/手机稳定器.png') },
|
||||
{ img: getStaticImage('equipment/相机稳定器.png') }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'light',
|
||||
name: '灯光',
|
||||
disabled: false,
|
||||
titleImg: getStaticImage('equipment/灯标题.png'),
|
||||
contentImg: getStaticImage('equipment/灯内容.png'),
|
||||
products: [
|
||||
{ img: getStaticImage('equipment/led补光灯.png') },
|
||||
{ img: getStaticImage('equipment/环形灯产品.png') }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'filter',
|
||||
name: '滤镜',
|
||||
disabled: false,
|
||||
titleImg: getStaticImage('equipment/滤镜标题.png'),
|
||||
contentImg: getStaticImage('equipment/滤镜内容.png'),
|
||||
products: [{ img: getStaticImage('equipment/滤镜产品.png') }]
|
||||
}
|
||||
])
|
||||
|
||||
const phoneList = ref([
|
||||
{ phoneImg: getStaticImage('film/equipment/相机产品.png') },
|
||||
{ phoneImg: getStaticImage('film/equipment/摄像机产品.png') },
|
||||
{ phoneImg: getStaticImage('film/equipment/微单产品.png') }
|
||||
])
|
||||
// 有资源的都解锁:如果某个类目没有任何产品资源,则自动禁用
|
||||
categories.value = categories.value.map(c => ({
|
||||
...c,
|
||||
disabled: (c.products?.length ?? 0) === 0
|
||||
}))
|
||||
|
||||
const platetitleList = ref([
|
||||
{ title: getStaticImage('film/equipment/相机标题.png') },
|
||||
{ title: getStaticImage('film/equipment/相机标题.png') },
|
||||
{ title: getStaticImage('film/equipment/相机标题.png') }
|
||||
])
|
||||
|
||||
const platetextList = ref([
|
||||
{ text: getStaticImage('film/equipment/相机内容.png') },
|
||||
{ text: getStaticImage('film/equipment/相机内容.png') },
|
||||
{ text: getStaticImage('film/equipment/相机内容.png') }
|
||||
])
|
||||
const currentCategory = computed(
|
||||
() => categories.value[selectedMenuIndex.value] ?? categories.value[0]
|
||||
)
|
||||
const currentProducts = computed(() => currentCategory.value?.products ?? [])
|
||||
const currentTitleImg = computed(() => currentCategory.value?.titleImg ?? '')
|
||||
const currentContentImg = computed(() => currentCategory.value?.contentImg ?? '')
|
||||
const hiddenbarclick = () => {
|
||||
hiddenbar.value = !hiddenbar.value
|
||||
}
|
||||
@@ -71,15 +129,15 @@ const hiddenbarclick = () => {
|
||||
// 选择菜单项
|
||||
const selectMenuItem = (index: number, item: { id: string; name: string; disabled: boolean }) => {
|
||||
if (item.disabled) {
|
||||
console.log(`菜单项 "${item.name}" 已被禁用`)
|
||||
return
|
||||
}
|
||||
selectedMenuIndex.value = index
|
||||
currentIndex.value = 0
|
||||
}
|
||||
|
||||
// 下一张图片
|
||||
const nextPhone = () => {
|
||||
if (currentIndex.value < phoneList.value.length - 1) {
|
||||
if (currentIndex.value < currentProducts.value.length - 1) {
|
||||
currentIndex.value++
|
||||
}
|
||||
}
|
||||
@@ -90,13 +148,9 @@ const prevPhone = () => {
|
||||
currentIndex.value--
|
||||
}
|
||||
}
|
||||
|
||||
const handleStepChange = (_: any, index: number) => {
|
||||
stepIndex.value = index
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="equipment-index">
|
||||
<div class="equipment-index overflow-hidden">
|
||||
<!-- 背景图片 -->
|
||||
<div
|
||||
class="absolute inset-0 bg-cover bg-center bg-no-repeat equipment-bg"
|
||||
@@ -109,7 +163,7 @@ const handleStepChange = (_: any, index: number) => {
|
||||
<div class="equipment-item" :hidden="hiddenbar">
|
||||
<div class="menu-list">
|
||||
<div
|
||||
v-for="(item, index) in menuItems"
|
||||
v-for="(item, index) in categories"
|
||||
:key="item.id"
|
||||
class="menu-item"
|
||||
:class="{
|
||||
@@ -155,12 +209,12 @@ const handleStepChange = (_: any, index: number) => {
|
||||
<img src="@/assets/images/equipment/按钮-左滑.png" alt="" />
|
||||
</div>
|
||||
<div class="equipment-phoneImg">
|
||||
<img :src="phoneList[currentIndex].phoneImg" alt="" />
|
||||
<img :src="currentProducts[currentIndex]?.img" alt="" />
|
||||
</div>
|
||||
<div
|
||||
class="equipment-rightbutton"
|
||||
@click="nextPhone"
|
||||
:style="{ visibility: currentIndex < phoneList.length - 1 ? 'visible' : 'hidden' }"
|
||||
:style="{ visibility: currentIndex < currentProducts.length - 1 ? 'visible' : 'hidden' }"
|
||||
>
|
||||
<img src="@/assets/images/equipment/按钮-右滑.png" alt="" />
|
||||
</div>
|
||||
@@ -171,11 +225,11 @@ const handleStepChange = (_: any, index: number) => {
|
||||
</div>
|
||||
|
||||
<div class="title">
|
||||
<img :src="platetitleList[currentIndex].title" alt="" />
|
||||
<img :src="currentTitleImg" alt="" />
|
||||
</div>
|
||||
<div class="plate-content">
|
||||
<div class="text">
|
||||
<img :src="platetextList[currentIndex].text" alt="" />
|
||||
<img :src="currentContentImg" alt="" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -193,10 +247,8 @@ const handleStepChange = (_: any, index: number) => {
|
||||
<GuideSteps
|
||||
:visible="true"
|
||||
:steps="steps"
|
||||
:highlight-selector="currentHighlight"
|
||||
:highlight-padding="12"
|
||||
:highlight-radius="20"
|
||||
@step-change="handleStepChange"
|
||||
position-offset-y="85%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,14 +3,10 @@ import { useRouter } from 'vue-router'
|
||||
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
|
||||
import { ImgButton } from '@/components/Button'
|
||||
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
|
||||
import { useFilmStore } from '@/stores'
|
||||
|
||||
const film = useFilmStore()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const handleClose = () => {
|
||||
film.setFilmUnlockProgress(3)
|
||||
router.back()
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import StartOverlay from '../components/StartOverlay.vue'
|
||||
import LoadingOverlay from '@/components/Loading/LoadingOverlay.vue'
|
||||
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useFilmStore } from '@/stores'
|
||||
|
||||
const router = useRouter()
|
||||
const start = ref(false)
|
||||
@@ -114,7 +113,6 @@ const captureScene = async (): Promise<void> => {
|
||||
*/
|
||||
const handleComplete = () => {
|
||||
showGuide.value = false
|
||||
useFilmStore().setFilmUnlockProgress(3)
|
||||
router.push({
|
||||
name: 'Film'
|
||||
})
|
||||
@@ -281,7 +279,7 @@ const handleComplete = () => {
|
||||
|
||||
<!-- 引导步骤 -->
|
||||
<GuideSteps v-if="showGuide" positionOffsetY="80%" @complete="handleComplete">
|
||||
<GuideStepsItem> 实地取景已全部完成,现在跟着我走进现场开始正式的拍摄吧~ </GuideStepsItem>
|
||||
<GuideStepsItem>实地取景已全部完成,现在跟着我走进现场开始正式的拍摄吧~</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
</div>
|
||||
</template>
|
||||
@@ -41,15 +41,6 @@ const steps = ref([
|
||||
}
|
||||
])
|
||||
|
||||
const currentIdx = ref<number>(0)
|
||||
const handleStepChange = (_: any, index: number) => {
|
||||
currentIdx.value = index
|
||||
}
|
||||
|
||||
const currentHighlight = computed(() => {
|
||||
return steps.value[currentIdx.value]?.highlightSelector
|
||||
})
|
||||
|
||||
const guideSteps = ref([
|
||||
{
|
||||
id: '1',
|
||||
@@ -67,14 +58,6 @@ const guideSteps = ref([
|
||||
highlightSelector: '.btn-item-3'
|
||||
}
|
||||
])
|
||||
const currentIndex = ref<number>(0)
|
||||
const handleGuideStepChange = (_: any, index: number) => {
|
||||
currentIndex.value = index
|
||||
}
|
||||
const currentHighlightSelector = computed(() => {
|
||||
return guideSteps.value[currentIndex.value ?? 0]?.highlightSelector
|
||||
})
|
||||
|
||||
const showModal = ref(false)
|
||||
|
||||
const showModal2 = ref(false)
|
||||
@@ -134,7 +117,6 @@ const handleClickB = () => {
|
||||
router.push({ name: 'FilmFilmingLocationB' })
|
||||
}
|
||||
const back = () => {
|
||||
film.setFilmUnlockProgress(3)
|
||||
// 跳转设备准备
|
||||
router.back()
|
||||
}
|
||||
@@ -167,11 +149,9 @@ const back = () => {
|
||||
<GuideSteps
|
||||
:visible="true"
|
||||
:steps="steps"
|
||||
:highlight-selector="currentHighlight"
|
||||
:highlight-padding="12"
|
||||
:highlight-radius="20"
|
||||
position-offset-y="20%"
|
||||
@step-change="handleStepChange"
|
||||
/>
|
||||
|
||||
<!-- 保存图片弹窗 -->
|
||||
@@ -219,12 +199,9 @@ const back = () => {
|
||||
:visible="true"
|
||||
:steps="guideSteps"
|
||||
:keyboard-navigation="true"
|
||||
:highlight-selector="currentHighlightSelector"
|
||||
:highlight-padding="12"
|
||||
:highlight-radius="16"
|
||||
position-offset-y="85%"
|
||||
@step-change="handleGuideStepChange"
|
||||
@complete="currentIndex = 0"
|
||||
/>
|
||||
|
||||
<!-- 知识卡片 -->
|
||||
|
||||
@@ -3,7 +3,6 @@ import { IMAGE_PATHS, getStaticImage } from '@/utils'
|
||||
import { ImgButton } from '@/components/Button'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -20,10 +19,10 @@ const back = () => {
|
||||
@click="back"
|
||||
/>
|
||||
<!-- 背景 -->
|
||||
<img class="absolute inset-0" :src="getStaticImage('film/img/背景.png')" alt="" />
|
||||
<img class="absolute inset-0" :src="getStaticImage('img/背景.png')" alt="" />
|
||||
|
||||
<!-- 标题 -->
|
||||
<img class="absolute top-5 z-10" :src="getStaticImage('film/img/资源筹备-标题.png')" alt="" />
|
||||
<img class="absolute top-5 z-10" :src="getStaticImage('img/资源筹备-标题.png')" alt="" />
|
||||
|
||||
<!-- 内容 -->
|
||||
<div class="relative h-full pt-28 overflow-y-scroll">
|
||||
|
||||
@@ -33,10 +33,10 @@ const handleOpenImages = () => {
|
||||
@click="back"
|
||||
/>
|
||||
<!-- 背景 -->
|
||||
<img class="absolute inset-0" :src="getStaticImage('film/img/背景.png')" alt="" />
|
||||
<img class="absolute inset-0" :src="getStaticImage('img/背景.png')" alt="" />
|
||||
|
||||
<!-- 标题 -->
|
||||
<img class="absolute top-5 z-10" :src="getStaticImage('film/img/脚本编写-标题.png')" alt="" />
|
||||
<img class="absolute top-5 z-10" :src="getStaticImage('img/脚本编写-标题.png')" alt="" />
|
||||
|
||||
<!-- 内容 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] -->
|
||||
<div class="relative h-full pt-28 overflow-y-scroll">
|
||||
|
||||
@@ -1,34 +1,30 @@
|
||||
<script lang="tsx" setup>
|
||||
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
|
||||
import { GuideSteps, GuideStepsItem, type GuideStep } from '@/components/Guide'
|
||||
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ImagePreview } from '@/components'
|
||||
import { useScale } from '@/composables/useScale'
|
||||
import { useFilmStore } from '@/stores'
|
||||
const router = useRouter()
|
||||
const showDoubao = ref(false)
|
||||
const film = useFilmStore()
|
||||
const steps = [
|
||||
{ id: 'step1', content: '先认识 Vlog:点击右侧「1.vlog初识」继续' },
|
||||
{ id: 'step2', content: '这是主要内容区域的边框' }
|
||||
{
|
||||
id: 'step1',
|
||||
content: '先认识 Vlog:点击右侧「1.vlog初识」继续',
|
||||
highlightSelector: '.img-switch-item--vlog'
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
content: '这是主要内容区域的边框',
|
||||
highlightSelector: '.work-border'
|
||||
}
|
||||
]
|
||||
|
||||
const stepIndex = ref(1)
|
||||
const back = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
const handleStepChange = (_step: GuideStep | null, index: number) => {
|
||||
stepIndex.value = index
|
||||
}
|
||||
|
||||
const currentHighlight = computed(() =>
|
||||
stepIndex.value === 0 ? '.img-switch-item--vlog' : '.work-border'
|
||||
)
|
||||
|
||||
const clickdoubao = () => {
|
||||
film.setFilmUnlockProgress(2)
|
||||
router.push({
|
||||
name: 'Film'
|
||||
})
|
||||
@@ -105,10 +101,8 @@ onMounted(() => {
|
||||
<GuideSteps
|
||||
v-if="showDoubao"
|
||||
:steps="steps"
|
||||
:highlight-selector="currentHighlight"
|
||||
:highlight-padding="12"
|
||||
:highlight-radius="20"
|
||||
@step-change="handleStepChange"
|
||||
position-offset-y="85%"
|
||||
>
|
||||
<GuideStepsItem>
|
||||
|
||||
@@ -5,16 +5,12 @@ import { useRouter } from 'vue-router'
|
||||
import { useScale } from '@/composables/useScale'
|
||||
import { ImagePreview } from '@/components'
|
||||
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
|
||||
import { useFilmStore } from '@/stores'
|
||||
|
||||
const { scale, offsetX, offsetY } = useScale()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const film = useFilmStore()
|
||||
|
||||
const back = () => {
|
||||
film.setFilmUnlockProgress(2)
|
||||
router.back()
|
||||
}
|
||||
|
||||
@@ -36,10 +32,10 @@ const handleOpenImages = () => {
|
||||
@click="back"
|
||||
/>
|
||||
<!-- 背景 -->
|
||||
<img class="absolute inset-0" :src="getStaticImage('film/img/背景.png')" alt="" />
|
||||
<img class="absolute inset-0" :src="getStaticImage('img/背景.png')" alt="" />
|
||||
|
||||
<!-- 标题 -->
|
||||
<img class="absolute top-5 z-10" :src="getStaticImage('film/img/分镜制作-标题.png')" alt="" />
|
||||
<img class="absolute top-5 z-10" :src="getStaticImage('img/分镜制作-标题.png')" alt="" />
|
||||
|
||||
<!-- 内容 -->
|
||||
<div class="relative h-full pt-28 overflow-y-scroll">
|
||||
|
||||
@@ -209,11 +209,6 @@ const handleComplete = () => {
|
||||
router.push({
|
||||
name: 'FilmWork'
|
||||
})
|
||||
} else {
|
||||
// if (film.FilmUnlockProgress == 0) film.setFilmUnlockProgress(1)
|
||||
// router.push({
|
||||
// name: 'Film'
|
||||
// })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,7 +327,8 @@ onMounted(() => {
|
||||
<div class="flex flex-wrap items-center justify-start">
|
||||
<div>接下来,让我们一起看看打造电影感的几种关键技巧吧。</div>
|
||||
<div class="text-yellow-400">
|
||||
点击<button
|
||||
点击
|
||||
<button
|
||||
class="w-10 h-10 bg-blue-500/80 hover:bg-blue-500 rounded-full inline-flex items-center justify-center text-white transition-all duration-200"
|
||||
title="步骤导航"
|
||||
>
|
||||
@@ -361,8 +357,8 @@ onMounted(() => {
|
||||
<GuideStepsItem>
|
||||
一个好镜头,既要好看,又要好懂 —— 既有视觉吸引力,又能把信息清晰地传达出来。
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem> 当这些条件被满足,一个镜头就有了“高级感”。 </GuideStepsItem>
|
||||
<GuideStepsItem> 而优秀的短视频,就是靠无数这样的好镜头累积而成的。 </GuideStepsItem>
|
||||
<GuideStepsItem>当这些条件被满足,一个镜头就有了“高级感”。</GuideStepsItem>
|
||||
<GuideStepsItem>而优秀的短视频,就是靠无数这样的好镜头累积而成的。</GuideStepsItem>
|
||||
<GuideStepsItem>
|
||||
接下来,我们就以徐汇滨江为例,一起动手练习如何拍出属于它的意义与故事吧!
|
||||
</GuideStepsItem>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="tsx" name="Work" setup>
|
||||
defineOptions({ name: 'Work' })
|
||||
<script lang="tsx" name="FilmWork" setup>
|
||||
defineOptions({ name: 'FilmWork' })
|
||||
import { ref, computed } from 'vue'
|
||||
import { getBackgroundImage, IMAGE_PATHS, getStaticImage } from '@/utils'
|
||||
import { ImgButton } from '@/components/Button'
|
||||
@@ -146,8 +146,8 @@ function openImages(urls: string[], startIndex = 0) {
|
||||
<span
|
||||
class="mt-24 text-white font-bold text-6xl [text-shadow:0_2px_4px_rgba(0,0,0,0.2),0_0_6px_rgba(0,207,255,0.9),0_0_14px_rgba(0,207,255,0.7)]"
|
||||
>
|
||||
{{ currentItem?.description }}</span
|
||||
>
|
||||
{{ currentItem?.description }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- <div class="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
|
||||
@@ -199,11 +199,9 @@ function openImages(urls: string[], startIndex = 0) {
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem>
|
||||
<div class="flex items-center justify-start">
|
||||
请点击 <img
|
||||
class="w-16"
|
||||
:src="getStaticImage('film/sx')"
|
||||
alt=""
|
||||
/> ,进入案例赏析!
|
||||
请点击
|
||||
<img class="w-16" :src="getStaticImage('film/sx')" alt="" />
|
||||
,进入案例赏析!
|
||||
</div>
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
|
||||
@@ -2,33 +2,135 @@
|
||||
defineOptions({
|
||||
name: 'Action'
|
||||
})
|
||||
import { getBackgroundImage, IMAGE_PATHS, getStaticImage } from '@/utils'
|
||||
import { getBackgroundImage, getPublicPath, IMAGE_PATHS, getStaticImage } from '@/utils'
|
||||
import { ImgButton } from '@/components/Button'
|
||||
import { ModuleSwitcher } from './components/ModuleSwitcher'
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore, useFilmStore, useVlogStore } from '@/stores'
|
||||
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { useRouter, type RouteRecordName } from 'vue-router'
|
||||
import {
|
||||
useUserStore,
|
||||
useFilmStore,
|
||||
useVlogStore,
|
||||
useInterviewStore,
|
||||
useBasicElementsStore
|
||||
} from '@/stores'
|
||||
import { GuideSteps } from '@/components/Guide'
|
||||
import type { GuideStep } from '@/components/Guide/types'
|
||||
|
||||
//点击电视机按钮引导状态
|
||||
const status = ref(false)
|
||||
const status2 = ref(false)
|
||||
const status3 = ref(false)
|
||||
type GuideChildNode = {
|
||||
id: number
|
||||
label: string
|
||||
icon: string
|
||||
routeName: RouteRecordName
|
||||
}
|
||||
|
||||
const stepIndex = ref(0)
|
||||
type GuideNode = {
|
||||
id: number
|
||||
num: string
|
||||
label: string
|
||||
x: number
|
||||
y: number
|
||||
hasChildren: boolean
|
||||
routeName?: RouteRecordName
|
||||
children?: GuideChildNode[]
|
||||
}
|
||||
|
||||
const handleComplete = () => {
|
||||
status.value = false
|
||||
const homeGuideShow = ref(true)
|
||||
const unlockedHomeGuideShow = ref(false)
|
||||
const elementGuideShow = ref(false)
|
||||
const unlockedHomeGuideCompleted = ref(false)
|
||||
|
||||
const homeGuideSteps: GuideStep[] = [
|
||||
{
|
||||
id: 'home-guide-navigation',
|
||||
content: '点击新手导航按钮,可以展开/收起新手导航面板!',
|
||||
positionOffsetY: '50%',
|
||||
highlightSelector: '.newUserGuide',
|
||||
audioSrc: getPublicPath('audio/py_0_005.MP3')
|
||||
},
|
||||
{
|
||||
id: 'home-guide-locked-modules',
|
||||
content: '新手导航模块全部完成后才能解锁三大进阶模块哦~',
|
||||
highlightSelector: '.newUserGuide',
|
||||
positionOffsetY: '50%',
|
||||
audioSrc: getPublicPath('audio/py_0_006.MP3')
|
||||
},
|
||||
{
|
||||
id: 'home-guide-module-switcher',
|
||||
content: '舞台划分为三大创作板块!点击 Vlog、访谈、电影的对应区域,右侧屏幕就会切换到对应板块!',
|
||||
highlightSelector: '.module-switcher-guide',
|
||||
positionOffsetY: '85%',
|
||||
audioSrc: getPublicPath('audio/py_0_007.MP3')
|
||||
},
|
||||
{
|
||||
id: 'home-guide-tv',
|
||||
content: '再点击右侧屏幕就会开启对应的板块学习哦!',
|
||||
highlightSelector: '.tvGuide',
|
||||
positionOffsetY: '80%',
|
||||
audioSrc: getPublicPath('audio/py_0_008.MP3')
|
||||
},
|
||||
{
|
||||
id: 'home-guide-skill',
|
||||
content: '点击技能树,可以一键回顾新手导航和三大创作板块所有的知识点哦!',
|
||||
highlightSelector: '.skill-tree',
|
||||
positionOffsetY: '50%',
|
||||
audioSrc: getPublicPath('audio/py_0_009.MP3')
|
||||
}
|
||||
]
|
||||
|
||||
const unlockedHomeGuideSteps: GuideStep[] = [
|
||||
{
|
||||
id: 'home-guide-welcome',
|
||||
content: '欢迎来到新手村!在这里,我会带着你解锁短视频创作的核心基本功!',
|
||||
audioSrc: getPublicPath('audio/py_1_001.MP3')
|
||||
},
|
||||
{
|
||||
id: 'home-guide-map',
|
||||
content: '我们会读懂构图、用好光线、灵活运镜,了解基础拍摄技巧和剪映软件的基本操作。',
|
||||
audioSrc: getPublicPath('audio/py_1_002.MP3')
|
||||
},
|
||||
{
|
||||
id: 'home-guide-start-node',
|
||||
content: '选择任意节点开始学习~',
|
||||
highlightSelector: '.guide-content',
|
||||
positionOffsetY: '20%',
|
||||
audioSrc: getPublicPath('audio/py_1_003.MP3')
|
||||
}
|
||||
]
|
||||
|
||||
const elementGuideSteps: GuideStep[] = [
|
||||
{
|
||||
id: 'home-guide-start-element',
|
||||
content: '选择任意要素开始学习~',
|
||||
highlightSelector: '.btn',
|
||||
positionOffsetY: '20%',
|
||||
audioSrc: getPublicPath('audio/py_1_004.MP3')
|
||||
}
|
||||
]
|
||||
|
||||
const handleHomeGuideComplete = () => {
|
||||
homeGuideShow.value = false
|
||||
}
|
||||
|
||||
const handleUnlockedHomeGuideComplete = () => {
|
||||
unlockedHomeGuideShow.value = false
|
||||
}
|
||||
|
||||
const handleElementGuideComplete = () => {
|
||||
elementGuideShow.value = false
|
||||
unlockedHomeGuideCompleted.value = true
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const user = useUserStore()
|
||||
const film = useFilmStore()
|
||||
const vlog = useVlogStore()
|
||||
const interview = useInterviewStore()
|
||||
const basicElements = useBasicElementsStore()
|
||||
|
||||
// 响应式状态
|
||||
const guideShow = ref(false)
|
||||
const selectedModule = ref<'vlog' | 'film' | 'interview'>('film')
|
||||
const selectedModule = ref<'vlog' | 'film' | 'interview'>('vlog')
|
||||
const isImageLoading = ref(false)
|
||||
|
||||
// 预加载图片映射
|
||||
@@ -39,7 +141,7 @@ const imageMap = {
|
||||
}
|
||||
|
||||
//获取新手导航引导状态
|
||||
const isGuideNewUser = computed(() => user.isGuideNewUser)
|
||||
const isGuideNewUser = computed(() => basicElements.isGuideNewUser)
|
||||
|
||||
// 预加载所有图片
|
||||
const preloadImages = () => {
|
||||
@@ -52,19 +154,20 @@ const preloadImages = () => {
|
||||
|
||||
// 事件处理函数
|
||||
const handleOpenGuide = () => {
|
||||
status2.value = true
|
||||
guideShow.value = true
|
||||
if (!homeGuideShow.value && isGuideNewUser.value) {
|
||||
unlockedHomeGuideShow.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleCloseGuide = () => {
|
||||
guideShow.value = false
|
||||
if (isGuideNewUser.value) {
|
||||
user.setIsGuideNewUser(false)
|
||||
basicElements.setIsGuideNewUser(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleModuleChange = (module: 'vlog' | 'film' | 'interview') => {
|
||||
status.value = true
|
||||
if (selectedModule.value === module) return
|
||||
|
||||
isImageLoading.value = true
|
||||
@@ -77,7 +180,7 @@ const handleModuleChange = (module: 'vlog' | 'film' | 'interview') => {
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
if (selectedModule.value === 'interview') return
|
||||
// if (selectedModule.value === 'interview') return
|
||||
// console.log(selectedModule.value.charAt(0).toUpperCase() + selectedModule.value.slice(1))
|
||||
router.push({
|
||||
//首字母大写
|
||||
@@ -90,35 +193,28 @@ const selectedImage = computed(() => {
|
||||
return getBackgroundImage(imageMap[selectedModule.value])
|
||||
})
|
||||
|
||||
const hasFinishedAllHomeGuides = computed(() => unlockedHomeGuideCompleted.value)
|
||||
const isSkillDisabled = computed(() => !hasFinishedAllHomeGuides.value)
|
||||
|
||||
const handleSkill = () => {
|
||||
if (isSkillDisabled.value) return
|
||||
|
||||
router.push({
|
||||
name: 'Skill'
|
||||
})
|
||||
}
|
||||
|
||||
// 清除所有与课程相关的缓存(Film / Vlog / User)
|
||||
// 清除所有与课程相关的缓存
|
||||
const handleClearCache = () => {
|
||||
// 直接通过 $patch 重置各模块的状态,避免热更新导致的方法缺失问题
|
||||
film.$patch({
|
||||
isGuideFilmTask: true,
|
||||
hasShownGuideFilmTask: false,
|
||||
isFilmUnlock: false,
|
||||
FilmUnlockProgress: 0
|
||||
})
|
||||
|
||||
vlog.$patch({
|
||||
isGuideVlogTask: true,
|
||||
isVlogUnlock: false,
|
||||
vlogUnlockProgress: 0
|
||||
})
|
||||
|
||||
user.$patch({
|
||||
isGuideNewUser: true
|
||||
})
|
||||
film.resetFilmState()
|
||||
vlog.resetVlogState()
|
||||
interview.resetInterviewState()
|
||||
basicElements.resetBasicElementsState()
|
||||
user.resetUserState()
|
||||
}
|
||||
|
||||
// 新手导航数据
|
||||
const nodes = [
|
||||
const nodes: GuideNode[] = [
|
||||
{
|
||||
id: 1,
|
||||
num: '1',
|
||||
@@ -156,33 +252,31 @@ const nodes = [
|
||||
const showChildrenNodeId = ref<number | null>(null)
|
||||
|
||||
// 处理节点点击
|
||||
const handleNodeClick = (node: any, event?: MouseEvent) => {
|
||||
if (event) {
|
||||
event.stopPropagation()
|
||||
}
|
||||
const handleNodeClick = (node: GuideNode, event?: MouseEvent) => {
|
||||
event?.stopPropagation()
|
||||
|
||||
if (node.hasChildren) {
|
||||
// 如果点击的是有子节点的节点,切换显示状态
|
||||
if (showChildrenNodeId.value === node.id) {
|
||||
showChildrenNodeId.value = null
|
||||
} else {
|
||||
showChildrenNodeId.value = node.id
|
||||
status3.value = true
|
||||
}
|
||||
} else {
|
||||
// 如果点击的是没有子节点的节点,直接跳转
|
||||
if (!node.hasChildren) {
|
||||
if (node.routeName) {
|
||||
router.push({ name: node.routeName })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (showChildrenNodeId.value === node.id) {
|
||||
showChildrenNodeId.value = null
|
||||
return
|
||||
}
|
||||
|
||||
showChildrenNodeId.value = node.id
|
||||
if (!unlockedHomeGuideShow.value && !unlockedHomeGuideCompleted.value) {
|
||||
elementGuideShow.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 处理子节点点击
|
||||
const handleChildNodeClick = (childNode: any, event: MouseEvent) => {
|
||||
const handleChildNodeClick = (childNode: GuideChildNode, event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
if (childNode.routeName) {
|
||||
router.push({ name: childNode.routeName })
|
||||
}
|
||||
router.push({ name: childNode.routeName })
|
||||
}
|
||||
|
||||
// 点击外部关闭子节点
|
||||
@@ -194,26 +288,28 @@ const handleClickOutside = () => {
|
||||
onMounted(() => {
|
||||
preloadImages()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 清理工作
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full h-full relative">
|
||||
<!-- 图片引导 -->
|
||||
<div
|
||||
class="absolute top-0 left-0 w-full h-full z-50"
|
||||
v-if="stepIndex < 4 && isGuideNewUser"
|
||||
@click="stepIndex++"
|
||||
>
|
||||
<img v-if="stepIndex === 0" :src="getStaticImage('home/1')" alt="" />
|
||||
<img v-else-if="stepIndex === 1" :src="getStaticImage('home/2')" alt="" />
|
||||
<img v-else-if="stepIndex === 2" :src="getStaticImage('home/3')" alt="" />
|
||||
<img v-else-if="stepIndex === 3" :src="getStaticImage('home/4')" alt="" />
|
||||
<div v-else class="absolute top-0 left-0 w-full h-full bg-black/50"></div>
|
||||
</div>
|
||||
<GuideSteps
|
||||
v-if="isGuideNewUser && homeGuideShow"
|
||||
:steps="homeGuideSteps"
|
||||
position-offset-y="82%"
|
||||
@complete="handleHomeGuideComplete"
|
||||
/>
|
||||
<GuideSteps
|
||||
v-if="unlockedHomeGuideShow"
|
||||
:steps="unlockedHomeGuideSteps"
|
||||
position-offset-y="82%"
|
||||
@complete="handleUnlockedHomeGuideComplete"
|
||||
/>
|
||||
<GuideSteps
|
||||
v-if="elementGuideShow"
|
||||
:steps="elementGuideSteps"
|
||||
position-offset-y="82%"
|
||||
@complete="handleElementGuideComplete"
|
||||
/>
|
||||
|
||||
<!-- 背景图片 -->
|
||||
<div
|
||||
@@ -229,11 +325,15 @@ onUnmounted(() => {
|
||||
class="w-10 cursor-pointer mx-2"
|
||||
src="@/assets/svg/clear.svg"
|
||||
@click="handleClearCache"
|
||||
/>清空缓存。
|
||||
/>
|
||||
清空缓存。
|
||||
</div>
|
||||
|
||||
<!-- 选择模块 -->
|
||||
<div :class="['absolute left-0 top-[-140px] inset-0 z-[8] pointer-events-none']">
|
||||
<div
|
||||
class="module-switcher-guide absolute w-[1380px] h-[600px] top-[460px] left-[calc(50%-690px)] pointer-events-none"
|
||||
></div>
|
||||
<ModuleSwitcher v-model="selectedModule" @change="handleModuleChange" />
|
||||
</div>
|
||||
|
||||
@@ -247,14 +347,17 @@ onUnmounted(() => {
|
||||
|
||||
<!-- 技能树 按钮 -->
|
||||
<ImgButton
|
||||
v-if="!isGuideNewUser"
|
||||
@click="handleSkill"
|
||||
:image="IMAGE_PATHS.ACTION.SKILLS_BTN"
|
||||
class="absolute bottom-0 right-0 z-30"
|
||||
:disabled="isSkillDisabled"
|
||||
class="absolute bottom-0 right-0 z-30 skill-tree"
|
||||
/>
|
||||
|
||||
<!-- 新手指导遮罩 -->
|
||||
<div v-if="isGuideNewUser" class="absolute inset-0 bg-black/50 z-10"></div>
|
||||
<div
|
||||
v-if="isGuideNewUser"
|
||||
:class="['absolute inset-0 z-10', homeGuideShow ? 'bg-black/0' : 'bg-black/50']"
|
||||
></div>
|
||||
|
||||
<!-- 新手指导 -->
|
||||
<div
|
||||
@@ -281,7 +384,7 @@ onUnmounted(() => {
|
||||
|
||||
<!-- 新手导航内容 -->
|
||||
<div class="relative w-[880px] h-[400px] mt-20">
|
||||
<div class="w-full h-full relative" @click="handleClickOutside">
|
||||
<div class="w-full h-full relative guide-content" @click="handleClickOutside">
|
||||
<!-- 标题区域 -->
|
||||
<div class="text-center mt-10 mb-8">
|
||||
<h2 class="text-5xl font-bold mb-3 text-[#7bfffd] tracking-wider drop-shadow-lg">
|
||||
@@ -395,49 +498,5 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 引导与高亮 -->
|
||||
<GuideSteps
|
||||
v-if="status"
|
||||
highlight-mode="gradient"
|
||||
highlight-selector=".tvGuide"
|
||||
position-offset-y="85%"
|
||||
@complete="handleComplete"
|
||||
>
|
||||
<GuideStepsItem>
|
||||
<div
|
||||
class="flex items-center justify-start"
|
||||
v-if="selectedModule === 'interview' || selectedModule === 'vlog'"
|
||||
>
|
||||
[{{
|
||||
selectedModule == 'interview'
|
||||
? '访谈-聆听与对话'
|
||||
: selectedModule == 'vlog'
|
||||
? 'vlog-创作与表达'
|
||||
: ''
|
||||
}}]模块建设中...
|
||||
</div>
|
||||
<div class="flex items-center justify-start" v-else>
|
||||
请点击<img
|
||||
class="w-20"
|
||||
:src="getStaticImage(imageMap[selectedModule])"
|
||||
alt=""
|
||||
/>,继续学习[{{
|
||||
selectedModule == 'film'
|
||||
? '电影-创作与表达'
|
||||
: selectedModule == 'vlog'
|
||||
? 'vlog-创作与表达'
|
||||
: ''
|
||||
}}]模块!
|
||||
</div>
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
|
||||
<GuideSteps :visible="status2" highlight-selector=".btn-items" position-offset-y="20%">
|
||||
<GuideStepsItem> 选择任意节点开始学习 </GuideStepsItem>
|
||||
</GuideSteps>
|
||||
<GuideSteps :visible="status3" highlight-selector=".btn" position-offset-y="20%">
|
||||
<GuideStepsItem> 选择任意要素开始学习 </GuideStepsItem>
|
||||
</GuideSteps>
|
||||
</div>
|
||||
</template>
|
||||