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

+10 -3
View File
@@ -37,7 +37,7 @@
</div>
<!-- 进度指示器 -->
<div class="progress-indicator">
<div class="progress-indicator flex items-center gap-6">
<div class="progress-dots">
<span
v-for="i in totalSteps"
@@ -46,6 +46,12 @@
:class="{ active: i <= currentStep }"
></span>
</div>
<!-- v-if="currentStep !== totalSteps" -->
<ImgButton
:image="IMAGE_PATHS.BUTTONS.CONTINUE"
class="continue-button"
@click="handleContinue"
/>
</div>
</div>
</div>
@@ -126,7 +132,7 @@ const positionStyle = computed(() => {
<style scoped>
.guide-dragon-container {
@apply w-full h-full relative overflow-hidden;
pointer-events: none;
pointer-events: auto;
z-index: 100002; /* 高于遮罩 */
}
@@ -145,6 +151,7 @@ const positionStyle = computed(() => {
height: 279px;
padding: 75px 30px 30px 120px;
background-color: rgba(0, 0, 0, 0); /* 背景完全透明 */
cursor: pointer;
}
.guide-header {
@@ -192,7 +199,7 @@ const positionStyle = computed(() => {
}
.progress-indicator {
@apply absolute bottom-6 right-10;
@apply absolute bottom-6 right-7;
}
.progress-dots {
+47 -14
View File
@@ -37,7 +37,10 @@
:total-steps="totalSteps"
@continue="handleNext"
>
{{ currentStepContent }}
<!-- 支持组件形式的 content -->
<component v-if="isCurrentStepComponent" :is="currentStepContentComponent" />
<!-- 支持字符串形式的 content -->
<div v-else v-html="currentStepContent"></div>
</GuideStepsItem>
</template>
</GuideDragon>
@@ -48,6 +51,7 @@
<script setup lang="ts">
import { ref, computed, useSlots, onMounted, onUnmounted, reactive } from 'vue'
import type { Component } from 'vue'
import SpotlightOverlay from './SpotlightOverlay.vue'
import GuideDragon from './GuideDragon.vue'
import GuideStepsItem from './GuideStepsItem.vue'
@@ -55,7 +59,7 @@ import GuideStepsItem from './GuideStepsItem.vue'
interface GuideStep {
id: string
title?: string
content: string
content: string | Component
nextText?: string
}
@@ -78,6 +82,8 @@ interface Props {
highlightMode?: 'border' | 'gradient'
/** 垂直偏移(仅Y轴),支持 number(px) 或 '10%'/'120px' */
positionOffsetY?: number | string
/** 是否可见,用于外部控制显示/隐藏 */
visible?: boolean
}
interface Emits {
@@ -95,14 +101,21 @@ const props = withDefaults(defineProps<Props>(), {
highlightPadding: 8,
highlightRadius: 16,
highlightMode: 'border',
positionOffsetY: '50%'
positionOffsetY: '50%',
visible: true
})
const emit = defineEmits<Emits>()
const slots = useSlots()
const show = ref(true)
const internalShow = ref(true)
const currentStepIndex = ref(0)
// 合并外部 visible 和内部 show 状态
// 只有当外部 visible 为 true 且内部 internalShow 也为 true 时才显示
const show = computed(() => {
return props.visible && internalShow.value
})
const alignStyle = reactive<{
left: string
top: string
@@ -116,7 +129,12 @@ const alignStyle = reactive<{
width: '1920px',
height: '1080px'
})
const highlightSelector = computed(() => props.highlightSelector || null)
// 验证并清理选择器,空字符串或只包含空格的选择器视为无效
const highlightSelector = computed(() => {
if (!props.highlightSelector) return null
const trimmed = props.highlightSelector.trim()
return trimmed.length > 0 ? trimmed : null
})
const highlightPadding = computed(() => props.highlightPadding || 0)
const highlightRadius = computed(() => props.highlightRadius || 0)
@@ -148,20 +166,34 @@ const currentStepComponent = computed(() => {
return slotSteps.value[currentStepIndex.value] || null
})
// 判断当前步骤的 content 是否为组件
const isCurrentStepComponent = computed(() => {
if (useSlotMode.value) return false
const step = currentStepData.value
if (!step) return false
return typeof step.content !== 'string'
})
// 当前步骤的内容(字符串形式)
const currentStepContent = computed(() => {
if (useSlotMode.value) return ''
if (isCurrentStepComponent.value) return ''
const step = currentStepData.value
if (!step) return ''
// if (!step.title) {
return `${step.content}`
// }
return typeof step.content === 'string' ? step.content : ''
})
// return `
// <h3 class="text-lg font-bold mb-2">${step.title}</h3>
// <div class="leading-relaxed">${step.content}</div>
// `
// 当前步骤的内容(组件形式)
const currentStepContentComponent = computed(() => {
if (useSlotMode.value) return null
if (!isCurrentStepComponent.value) return null
const step = currentStepData.value
if (!step) return null
return typeof step.content !== 'string' ? step.content : null
})
const handleNext = () => {
@@ -184,7 +216,7 @@ const handleSkip = () => {
}
const handleComplete = () => {
show.value = false
internalShow.value = false
emit('complete')
}
@@ -205,7 +237,7 @@ const goToStep = (index: number) => {
const resetGuide = () => {
currentStepIndex.value = 0
show.value = true
internalShow.value = true
}
// 键盘导航
@@ -309,6 +341,7 @@ defineExpose({
inset: 0;
z-index: 10001;
pointer-events: none;
font-family: 'DingTalkJinBuTi';
}
.guide-align {
+1 -7
View File
@@ -1,11 +1,6 @@
<template>
<span class="guide-steps-item">
<slot />
<ImgButton
v-if="currentStep !== totalSteps"
:image="IMAGE_PATHS.BUTTONS.CONTINUE"
class="continue-button"
/>
</span>
</template>
@@ -36,9 +31,8 @@ defineEmits<{
@apply inline;
}
.continue-button {
@apply inline-block ml-3;
@apply absolute bottom-4 right-6 hover:scale-105;
vertical-align: middle;
transform: scale(0.8);
}
/* .continue-button:hover {
+164 -11
View File
@@ -12,7 +12,7 @@
</template>
<script setup lang="ts">
import { reactive, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { reactive, computed, watch, onMounted, onBeforeUnmount, ref } from 'vue'
interface Props {
/** 是否可见 */
@@ -39,23 +39,175 @@ const props = withDefaults(defineProps<Props>(), {
})
const rect = reactive({ top: 0, left: 0, width: 0, height: 0 })
const isElementReady = ref(false)
// 重试相关变量
let retryCount = 0
const MAX_RETRY_COUNT = 100 // 最大重试次数(约 1.6 秒,60fps)
const RETRY_TIMEOUT = 2000 // 超时时间(毫秒)
let retryTimer: number | null = null
let mutationObserver: MutationObserver | null = null
let retryStartTime = 0
// 验证 CSS 选择器是否有效
// 主要检查:不能以纯数字开头(除非是 ID 选择器 #1)
const isValidSelector = (selector: string): boolean => {
if (!selector || selector.trim().length === 0) return false
const trimmed = selector.trim()
// CSS 选择器不能以数字开头(除非是 ID 选择器 #)
// 如果以数字开头且不是以 # 开头,则无效
if (/^\d/.test(trimmed) && !trimmed.startsWith('#')) {
return false
}
return true
}
// 检查元素是否存在且尺寸有效
const checkElement = (selector: string): HTMLElement | null => {
try {
const el = document.querySelector(selector) as HTMLElement | null
if (!el) return null
const r = el.getBoundingClientRect()
// 只有当元素有有效尺寸时才返回
if (r.width > 0 && r.height > 0) {
return el
}
return null
} catch (error) {
return null
}
}
const measure = () => {
if (!props.visible || !props.targetSelector) return
const el = document.querySelector(props.targetSelector) as HTMLElement | null
if (!el) return
const r = el.getBoundingClientRect()
const pad = Math.max(0, props.padding)
// 使用视口坐标(fixed层),不叠加滚动偏移
rect.top = r.top - pad
rect.left = r.left - pad
rect.width = r.width + pad * 2
rect.height = r.height + pad * 2
if (!props.visible || !props.targetSelector) {
isElementReady.value = false
rect.width = 0
rect.height = 0
return
}
// 验证选择器是否有效(不能是空字符串或只包含空格)
const selector = props.targetSelector.trim()
if (!selector || selector.length === 0) {
isElementReady.value = false
rect.width = 0
rect.height = 0
return
}
// 先进行基本格式验证,避免调用 querySelector 时抛出错误
if (!isValidSelector(selector)) {
isElementReady.value = false
rect.width = 0
rect.height = 0
return
}
const el = checkElement(selector)
if (el) {
// 元素已找到且尺寸有效
const r = el.getBoundingClientRect()
const pad = Math.max(0, props.padding)
// 使用视口坐标(fixed层),不叠加滚动偏移
rect.top = r.top - pad
rect.left = r.left - pad
rect.width = r.width + pad * 2
rect.height = r.height + pad * 2
isElementReady.value = true
// 停止重试
stopRetry()
} else {
// 元素未找到或尺寸无效,启动重试机制
isElementReady.value = false
rect.width = 0
rect.height = 0
startRetry(selector)
}
}
// 启动重试机制
const startRetry = (selector: string) => {
// 如果已经在重试中,不重复启动
if (retryTimer !== null || mutationObserver !== null) {
return
}
retryCount = 0
retryStartTime = Date.now()
// 使用 MutationObserver 监听 DOM 变化
mutationObserver = new MutationObserver(() => {
retryCheck(selector)
})
// 监听整个文档的变化
mutationObserver.observe(document.body, {
childList: true,
subtree: true,
attributes: false
})
// 同时使用 requestAnimationFrame 轮询检查(作为备用机制)
retryCheck(selector)
}
// 重试检查
const retryCheck = (selector: string) => {
// 检查超时
if (Date.now() - retryStartTime > RETRY_TIMEOUT) {
stopRetry()
return
}
// 检查重试次数
if (retryCount >= MAX_RETRY_COUNT) {
stopRetry()
return
}
retryCount++
const el = checkElement(selector)
if (el) {
// 元素已找到,更新 rect
const r = el.getBoundingClientRect()
const pad = Math.max(0, props.padding)
rect.top = r.top - pad
rect.left = r.left - pad
rect.width = r.width + pad * 2
rect.height = r.height + pad * 2
isElementReady.value = true
stopRetry()
} else {
// 继续重试
retryTimer = requestAnimationFrame(() => {
retryTimer = null
retryCheck(selector)
})
}
}
// 停止重试
const stopRetry = () => {
if (mutationObserver) {
mutationObserver.disconnect()
mutationObserver = null
}
if (retryTimer !== null) {
cancelAnimationFrame(retryTimer)
retryTimer = null
}
retryCount = 0
}
watch(
() => [props.visible, props.targetSelector, props.padding],
() => {
stopRetry() // 停止之前的重试
requestAnimationFrame(measure)
},
{ immediate: true }
@@ -73,6 +225,7 @@ onMounted(() => {
onBeforeUnmount(() => {
window.removeEventListener('resize', onResize)
window.removeEventListener('scroll', onScroll)
stopRetry()
})
// 计算渐变遮罩样式
+1 -1
View File
@@ -99,7 +99,7 @@ export interface StepItem {
id: string
label: string
content: string
type: 'shooting' | 'planning' | 'post' | 'other'
type?: 'shooting' | 'planning' | 'post' | 'other'
}
interface Props {
+4 -2
View File
@@ -1,8 +1,10 @@
import type { Component } from 'vue'
// 引导步骤类型定义
export interface GuideStep {
id: string
title: string
content: string
title?: string
content: string | Component
nextText?: string
}
@@ -28,9 +28,9 @@ const getImage = computed(() => {
// 覆盖图映射:根据构图类型和图片索引获取对应的覆盖图
const getOverlayImage = (index: number) => {
const overlayMap: Record<string, string[]> = {
'1': ['film/shooting/05引导线构图_覆盖', 'film/shooting/06引导线构图_覆盖'], // 框架构图
'1': ['film/shooting/01框架构图_覆盖', 'film/shooting/02框架构图_覆盖'], // 框架构图
'2': ['film/shooting/03对称构图_覆盖', 'film/shooting/04对称构图_覆盖'], // 对称构图
'3': ['film/shooting/01框架构图_覆盖', 'film/shooting/02框架构图_覆盖'] // 引导线构图
'3': ['film/shooting/05引导线构图_覆盖', 'film/shooting/06引导线构图_覆盖'] // 引导线构图
}
const images = overlayMap[props.compositionKey] || []
return images[index] || ''
+34 -16
View File
@@ -8,9 +8,9 @@
<!-- 主进度条 -->
<div class="progress-bar">
<!-- 中间步骤指示器 -->
<div class="steps-section" v-if="currentStep > 0">
<div class="steps-section" v-if="currentStep > 0 || completedStep > 0">
<!-- 标题区域 -->
<div class="absolute top-0 left-40" v-if="currentStep > 0">
<div class="absolute top-0 left-40" v-if="currentStep > 0 || completedStep > 0">
<img
v-if="moduleName === 'vlog'"
:src="getStaticImage(IMAGE_PATHS.PROGRESS_BAR.TITLE)"
@@ -51,11 +51,7 @@
:src="currentStep === index ? steps[index].nameSelect : steps[index].nameselectNot"
alt=""
/> -->
<div
class="flex items-center"
:class="canComplete ? 'cursor-not-allowed' : 'cursor-pointer'"
@click="handleStepClick(index + 1)"
>
<div class="flex items-center cursor-pointer" @click="handleStepClick(index + 1)">
<span
:class="[
'font-bold text-4xl ',
@@ -63,7 +59,8 @@
]"
>{{ step.name }}</span
>
<img v-if="canComplete" src="@/assets/images/midterm/完成.png" alt="" />
<!-- 图标 -->
<img v-if="isStepCompleted(index)" src="@/assets/images/midterm/完成.png" alt="" />
<img
v-else
:class="['ml-2', currentStep === index + 1 && 'filter-blue']"
@@ -72,9 +69,10 @@
/>
</div>
<!-- 连接线 -->
<template v-if="index < 2">
<img
v-if="canComplete"
v-if="isStepCompleted(index + 1)"
class="step-ellipsis"
src="@/assets/images/midterm/进度装饰(已完成.png"
alt=""
@@ -125,7 +123,9 @@ import { getStaticImage, IMAGE_PATHS } from '@/utils'
// 定义组件属性
interface Props {
/** 当前步骤索引 (0-3) */
/** 已完成的最大步骤编号 (1-4),表示已完成到第几步。1=前期准备, 2=中期拍摄, 3=后期剪辑, 4=实战操作台 */
completedStep?: number
/** 当前选中的步骤索引 (1-3),对应进度条显示的三个步骤 */
currentStep?: number
moduleName?: 'film' | 'vlog' | 'interview'
}
@@ -138,19 +138,34 @@ interface Emits {
stepChange: [number]
/** v-model:currentStep 双向绑定时触发 */
'update:currentStep': [number]
/** v-model:completedStep 双向绑定时触发 */
'update:completedStep': [number]
}
const props = withDefaults(defineProps<Props>(), {
currentStep: 0,
completedStep: 0,
currentStep: 1,
moduleName: 'vlog'
})
const [currentStep] = useModel(props, 'currentStep')
const [completedStep] = useModel(props, 'completedStep')
const emit = defineEmits<Emits>()
// 计算属性:是否可以完成(当前在最后一步)
const canComplete = computed(() => props.currentStep > steps.length)
// 计算属性:是否可以完成(前3步都已完成,解锁实战操作台)
// 步骤:1=前期准备, 2=中期拍摄, 3=后期剪辑, 4=实战操作台
// 当 completedStep >= 3(即完成第3步)时,解锁右侧完成按钮
const canComplete = computed(() => props.completedStep >= 3)
// 判断步骤是否已完成
const isStepCompleted = (stepIndex: number) => {
// stepIndex 是数组索引 (0, 1, 2),对应步骤编号 (1, 2, 3)
// completedStep 是已完成的最大步骤编号 (1-4)
// 如果 completedStep >= stepIndex + 1,说明该步骤已完成
const stepNumber = stepIndex + 1
return props.completedStep >= stepNumber
}
// 处理完成按钮点击
const handleComplete = () => {
@@ -160,7 +175,6 @@ const handleComplete = () => {
}
const handleStepClick = (index: number) => {
if (canComplete.value) return
currentStep.value = index
emit('stepChange', index)
}
@@ -189,8 +203,12 @@ const steps = [
// 获取步骤状态
const getStepStatus = (stepIndex: number) => {
if (stepIndex < props.currentStep) return 'completed'
if (stepIndex === props.currentStep) return 'current'
const stepNumber = stepIndex + 1 // 转换为步骤编号 (1, 2, 3)
// 如果已完成,返回 completed
if (props.completedStep >= stepNumber) return 'completed'
// 如果当前选中,返回 current
if (props.currentStep === stepNumber) return 'current'
// 否则返回 locked
return 'locked'
}
</script>
+1 -1
View File
@@ -152,7 +152,7 @@ const handleSubmit = () => {
<template>
<div class="absolute inset-0 flex items-center justify-center px-20 pointer-events-none z-30">
<div
class="bg-black/40 backdrop-blur-sm rounded-3xl border border-white/20 p-16 space-y-10 text-white pointer-events-auto"
class="bg-black/80 backdrop-blur-lg rounded-3xl border border-white/20 p-16 space-y-10 text-white pointer-events-auto"
>
<!-- 顶部:题号 -->
<div v-if="showQuestionNumber" class="flex items-center justify-between mb-2">