feat: 添加思维导图支持,更新引导步骤组件,优化视频播放与学习进度追踪功能

This commit is contained in:
zhongyidian committed 2026-01-20 17:54:05 +08:00
1 parent e9a1fee27f
commit c4c9be9d35
98 files changed
+3178 -551

No files matched your search

+61 -19
View File
@@ -7,13 +7,15 @@ import { ImgButton } from '@/components/Button'
import { ModuleSwitcher } from './components/ModuleSwitcher'
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores'
import { useUserStore, useFilmStore, useVlogStore } from '@/stores'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
//点击电视机按钮引导状态
const status = ref(false)
//点击新手导航节点引导状态
const status2 = ref(true)
const status2 = ref(false)
const status3 = ref(false)
const stepIndex = ref(0)
const handleComplete = () => {
status.value = false
@@ -21,6 +23,8 @@ const handleComplete = () => {
const router = useRouter()
const user = useUserStore()
const film = useFilmStore()
const vlog = useVlogStore()
// 响应式状态
const guideShow = ref(false)
@@ -48,6 +52,7 @@ const preloadImages = () => {
// 事件处理函数
const handleOpenGuide = () => {
status2.value = true
guideShow.value = true
}
@@ -91,6 +96,27 @@ const handleSkill = () => {
})
}
// 清除所有与课程相关的缓存(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
})
}
// 新手导航数据
const nodes = [
{
@@ -141,6 +167,7 @@ const handleNodeClick = (node: any, event?: MouseEvent) => {
showChildrenNodeId.value = null
} else {
showChildrenNodeId.value = node.id
status3.value = true
}
} else {
// 如果点击的是没有子节点的节点,直接跳转
@@ -175,12 +202,36 @@ onUnmounted(() => {
<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>
<!-- 背景图片 -->
<div
:class="['absolute inset-0 bg-cover bg-center bg-no-repeat']"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.ACTION_BG) }"
></div>
<div
class="absolute top-4 left-4 flex items-center text-white text-xl bg-black/10 px-4 py-2 rounded-xl"
>
!测试阶段友情提示:课程全部完成后,若需重新观看之前的内容,请按
<img
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']">
<ModuleSwitcher v-model="selectedModule" @change="handleModuleChange" />
@@ -240,7 +291,7 @@ onUnmounted(() => {
</div>
<!-- 节点图表 -->
<div class="absolute inset-0 top-[60px]">
<div class="absolute inset-0 top-[60px] btn-items">
<!-- 连线 (SVG) -->
<svg class="absolute inset-0 w-full h-full pointer-events-none">
<!-- 左侧装饰线 -> 节点1 -->
@@ -323,7 +374,7 @@ onUnmounted(() => {
<!-- 子节点图标(当点击三大基础要素时显示) -->
<div
v-if="node.hasChildren && showChildrenNodeId === node.id"
class="absolute -top-40 left-1/2 transform -translate-x-1/2 flex z-20"
class="absolute -top-40 left-1/2 transform -translate-x-1/2 flex z-20 btn"
>
<div
v-for="childNode in node.children"
@@ -382,20 +433,11 @@ onUnmounted(() => {
</GuideStepsItem>
</GuideSteps>
<GuideSteps
v-if="status2"
highlight-mode="gradient"
highlight-selector=".newUserGuide"
position-offset-y="50%"
@complete="handleComplete"
>
<GuideStepsItem>
<div class="flex items-center justify-start">
欢迎来到《短视频达人养成记》课程,请点击下方
<img class="w-20" :src="getStaticImage('home/action/top')" alt="" />
开始学习!
</div>
</GuideStepsItem>
<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>
@@ -7,7 +7,10 @@
{ selected: isSelected }
]"
>
<div :class="['btn-mask', module.maskConfig.className]" @click="$emit('click')"></div>
<div
:class="['btn-mask', `btn-mask-${module.type}`, module.maskConfig.className]"
@click="$emit('click')"
></div>
</div>
</template>
+306 -6
View File
@@ -1,6 +1,256 @@
<script lang="ts" setup>
import { getBackgroundImage, IMAGE_PATHS } from '@/utils'
import { getBackgroundImage, getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button'
import { GuideSteps, ImagePreview, Overlay } from '@/components'
import { computed, ref, h, nextTick } from 'vue'
import MindMap from 'simple-mind-map'
// XMind 解析工具(用于将 .xmind 文件解析为思维导图数据)
// @ts-expect-error: 官方包未提供类型声明
import xmindParser from 'simple-mind-map/src/parse/xmind.js'
import { useScale } from '@/composables/useScale'
// 第一个步骤的内容组件 - 使用函数组件形式
const Step1Content = () =>
h(
'div',
{
class: 'flex items-center justify-start'
},
[
'点击',
h('img', {
class: 'w-20',
src: getStaticImage(IMAGE_PATHS.BUTTONS.COURSE_FRAME),
alt: ''
}),
' 查看课程结构,快速了解每个模块的学习内容。'
]
)
const Step2Content = () =>
h(
'div',
{
class: 'flex items-center justify-start'
},
[
h('img', {
class: 'w-20 inline',
src: getStaticImage(IMAGE_PATHS.BUTTONS.OPERATE_GUIDE),
alt: ''
}),
'包含课程介绍和操作指南,观看指引视频,轻松上手课程。(资源建设中...)'
]
)
const Step3Content = () =>
h(
'div',
{
class: 'flex items-center justify-start'
},
[
'点击',
h('img', {
class: 'w-20',
src: getStaticImage(IMAGE_PATHS.BUTTONS.START_FILMING),
alt: ''
}),
' 正式进入课程,开启你的短视频拍摄之旅。'
]
)
// const Step3Content = () =>
// h(
// 'div',
// {
// class: 'flex items-center justify-start'
// },
// [
// '点击',
// h('img', {
// class: 'w-20',
// src: getStaticImage(IMAGE_PATHS.BUTTONS.COURSE_FRAME),
// alt: ''
// }),
// ' 查看课程结构,快速了解每个模块的学习内容。'
// ]
// )
const guideSteps = ref([
{
id: 'step-1',
title: '',
content: Step1Content,
highlightSelector: '.kecheng-wrapper'
},
{
id: 'step-2',
title: '',
content: Step2Content,
highlightSelector: '.zhiying-wrapper'
},
{
id: 'step-3',
title: '',
content: Step3Content,
highlightSelector: '.start-filming-wrapper'
}
])
const showGuide = ref(true)
const currentGuideStep = ref(0)
// 当前步骤的高亮选择器
const currentHighlightSelector = computed(() => {
if (showGuide.value && guideSteps.value[currentGuideStep.value]) {
return guideSteps.value[currentGuideStep.value].highlightSelector || null
}
return null
})
const handleGuideStepChange = (step: any, index: number) => {
currentGuideStep.value = index
}
const handleComplete = () => {
console.log('handleComplete')
}
// 课程框架思维导图弹层
const showMindMap = ref(false)
const mindMapContainer = ref<HTMLElement | null>(null)
let mindMapInstance: any = null
// 缓存解析后的思维导图数据,避免重复请求
let cachedMindMapData: any = null
const initMindMap = async () => {
if (!mindMapContainer.value) return
// 等待容器完全渲染并获取实际尺寸
await new Promise(resolve => setTimeout(resolve, 100))
console.log('容器尺寸:', {
width: mindMapContainer.value.clientWidth,
height: mindMapContainer.value.clientHeight
})
// 确保容器有尺寸
if (mindMapContainer.value.clientWidth === 0 || mindMapContainer.value.clientHeight === 0) {
console.error('容器尺寸为 0,等待渲染')
await new Promise(resolve => setTimeout(resolve, 200))
}
// 初始化思维导图实例(每次都重新创建,避免 DOM 销毁问题)
mindMapInstance = new MindMap({
el: mindMapContainer.value,
data: {
data: { text: '加载中...' },
children: []
}
})
// 创建后立即调用 resize
if (typeof mindMapInstance.resize === 'function') {
mindMapInstance.resize()
}
try {
// 如果有缓存数据,直接使用
if (cachedMindMapData) {
console.log('使用缓存数据')
mindMapInstance.setData(cachedMindMapData)
} else {
// 首次加载,从服务器获取文件
console.log('首次加载,获取 XMind 文件')
const res = await fetch(getPublicPath('maps/dsp_kecheng.xmind'))
if (!res.ok) {
throw new Error(`加载 XMind 失败: ${res.status}`)
}
const blob = await res.blob()
// 将远程文件转换成 File 对象,供解析器使用
const file = new File([blob], 'dsp_kecheng.xmind', {
type: 'application/vnd.xmind.workbook'
})
// 使用 simple-mind-map 提供的解析工具解析 xmind 文件
const data = await (xmindParser as any).parseXmindFile(file)
// 缓存数据
cachedMindMapData = data
// 将解析后的数据设置到思维导图实例
mindMapInstance.setData(data)
}
// 数据加载后再次调用 resize
if (typeof mindMapInstance.resize === 'function') {
mindMapInstance.resize()
}
// 等待渲染完成后调用视图居中
await new Promise(resolve => setTimeout(resolve, 100))
centerMindMapView()
} catch (error) {
console.error('加载 XMind 文件失败:', error)
}
}
// 居中并缩放视图的辅助函数
const centerMindMapView = () => {
if (!mindMapInstance) return
const anyInstance = mindMapInstance as any
console.log('尝试居中视图')
// 尝试多种方法来居中视图
if (anyInstance.view) {
// 方法1: fit - 自动缩放并居中
if (typeof anyInstance.view.fit === 'function') {
console.log('调用 view.fit()')
anyInstance.view.fit()
}
// 方法2: setScale + translateX/Y 来手动居中
else if (typeof anyInstance.view.setScale === 'function') {
console.log('手动设置缩放和位置')
anyInstance.view.setScale(1)
anyInstance.view.translateX(0)
anyInstance.view.translateY(0)
}
// 方法3: fitCenter
else if (typeof anyInstance.view.fitCenter === 'function') {
console.log('调用 view.fitCenter()')
anyInstance.view.fitCenter()
}
}
}
const { scale, offsetX, offsetY } = useScale()
const openMindMap = () => {
ImagePreview({
images: [getStaticImage('maps/dt')],
maskClosable: true,
getScaleMetrics: () => ({ scale: scale.value, offsetX: offsetX.value, offsetY: offsetY.value })
})
// showMindMap.value = true
// nextTick(() => {
// void initMindMap()
// })
}
const handleMindMapClose = () => {
showMindMap.value = false
// 销毁思维导图实例,避免 DOM 销毁后引用失效
if (mindMapInstance && typeof mindMapInstance.destroy === 'function') {
mindMapInstance.destroy()
}
mindMapInstance = null
// 保留缓存数据,下次打开时直接使用,不需要重新请求
}
</script>
<template>
@@ -12,16 +262,66 @@ import { ImgButton } from '@/components/Button'
></div>
<!-- 按钮 -->
<!-- 开始拍摄 -->
<div class="absolute right-[320px] bottom-[100px]">
<ImgButton :image="IMAGE_PATHS.BUTTONS.START_FILMING" :path="{ name: 'Action' }" />
<div class="absolute right-[420px] bottom-[200px] start-filming-wrapper">
<ImgButton image="home/action-btn" :path="{ name: 'Action' }" />
</div>
<!-- 课程框架 -->
<div class="absolute right-20 bottom-36">
<ImgButton :image="IMAGE_PATHS.BUTTONS.COURSE_FRAME" />
<div class="absolute right-20 bottom-36 kecheng-wrapper">
<ImgButton :image="IMAGE_PATHS.BUTTONS.COURSE_FRAME" @click="openMindMap" />
</div>
<!-- 操作指引 -->
<div class="absolute right-20 bottom-10">
<div class="absolute right-20 bottom-10 zhiying-wrapper">
<ImgButton :image="IMAGE_PATHS.BUTTONS.OPERATE_GUIDE" />
</div>
<GuideSteps
:visible="showGuide"
:steps="guideSteps"
:highlight-selector="currentHighlightSelector"
:highlight-padding="12"
:highlight-radius="16"
:keyboard-navigation="true"
highlight-mode="border"
position-offset-y="40%"
@complete="handleComplete"
@step-change="handleGuideStepChange"
/>
<!-- 课程框架思维导图弹层 -->
<!-- <Overlay :visible="showMindMap" @close="handleMindMapClose">
<div class="bg-white rounded-xl shadow-xl w-[100vw] h-[100vh] flex flex-col overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 border-b flex-shrink-0">
<div class="font-semibold text-lg">短视频达人课程框架</div>
<button
class="text-gray-500 hover:text-gray-700 text-2xl leading-none px-2"
type="button"
@click="handleMindMapClose"
>
×
</button>
</div>
<div class="flex-1 overflow-hidden">
<div ref="mindMapContainer" class="mindmap-container"></div>
</div>
</div>
</Overlay> -->
</div>
</template>
<style scoped>
.mindmap-container {
width: 100%;
height: 100%;
position: relative;
}
/* 确保 simple-mind-map 生成的 SVG 填充整个容器 */
.mindmap-container :deep(svg) {
width: 100% !important;
height: 100% !important;
}
.mindmap-container :deep(.smm-container) {
width: 100% !important;
height: 100% !important;
}
</style>