forked from zhongyidian/super_video
feat: 添加思维导图支持,更新引导步骤组件,优化视频播放与学习进度追踪功能
This commit is contained in:
1 parent
e9a1fee27f
commit
c4c9be9d35
98 files changed
+3178
-551
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
// 计算渐变遮罩样式
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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] || ''
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user