feat: 完善访谈与vlog模块页面,重组设备与图片公共资源,新增音频管理与一班对接工具

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
zhongyidianandClaude Opus 4.7 committed 2026-05-05 15:41:41 +08:00
1 parent c4c9be9d35
commit fc9633f371
159 files changed
+4823 -2535

No files matched your search

+33 -4
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 683 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

File renamed without changes.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 917 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 MiB

+5
View File
@@ -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>
+34 -21
View File
@@ -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
+35 -2
View File
@@ -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<{
+132 -325
View File
@@ -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>
+22
View File
@@ -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类型
+25 -4
View File
@@ -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)
}
+28 -5
View File
@@ -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"
>
+87 -2
View File
@@ -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;
+95 -2
View File
@@ -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 }
+145
View File
@@ -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
}
}
+41
View File
@@ -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
}
+37 -2
View File
@@ -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
+56
View File
@@ -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',
+81 -49
View File
@@ -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',
+75
View File
@@ -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
+107 -2
View File
@@ -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,
+111 -2
View File
@@ -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
+10 -7
View File
@@ -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
}
},
{
+172 -5
View File
@@ -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
}
},
{
+9
View File
@@ -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;
}
+226
View File
@@ -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>
+22 -6
View File
@@ -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="步骤导航"
>
+44 -2
View File
@@ -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">
+27 -10
View File
@@ -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>
+2 -2
View File
@@ -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">
+7 -10
View File
@@ -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"
/>
<!-- 知识卡片 -->
+2 -3
View File
@@ -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">
+2 -2
View File
@@ -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">
+11 -17
View File
@@ -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>
+2 -6
View File
@@ -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">
+4 -8
View File
@@ -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>
+7 -9
View File
@@ -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">
请点击 &nbsp;<img
class="w-16"
:src="getStaticImage('film/sx')"
alt=""
/>&nbsp;,进入案例赏析!
请点击 &nbsp;
<img class="w-16" :src="getStaticImage('film/sx')" alt="" />
&nbsp;,进入案例赏析!
</div>
</GuideStepsItem>
</GuideSteps>
+179 -120
View File
@@ -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>
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff. Show more