需求
在用户打开某个页面时,对页面中指定的元素进行弹框说明,实现类似“新手引导”的效果
效果图
该组件的特点
- 独立的组件,可以简单方便的引入页面使用,对已有页面几乎零侵入
- 通过CSS选择器的方式指定需要引导的元素
- 只需更改指定属性,就可以实现自定义配置
实现
<template>
<teleport to="body">
<transition name="fade">
<div v-if="visible" class="guide-overlay" @click="handleSkip">
<div
class="guide-tooltip"
:class="tooltipPosition"
:style="tooltipStyle"
@click.stop
>
<div class="tooltip-content">
<h4>{{ currentStepData.title }}</h4>
<p>{{ currentStepData.description }}</p>
<div class="tooltip-actions">
<span class="step-indicator">
{{ currentStepIndex + 1 }} / {{ steps.length }}
</span>
<div class="button-group">
<el-button
v-if="currentStepIndex < steps.length - 1"
size="small"
@click="handleSkip"
>
{{ skipText }}
</el-button>
<el-button
type="primary"
size="small"
@click="handleNext"
>
{{ currentStepIndex < steps.length - 1 ? nextText : finishText }}
</el-button>
</div>
</div>
</div>
</div>
</div>
</transition>
</teleport>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
const props = defineProps({
// 是否显示引导
modelValue: {
type: Boolean,
default: false
},
// 引导步骤配置
steps: {
type: Array,
required: true,
validator: steps => {
return steps.every(step =>
step.target && step.title && step.description
)
}
},
// 自定义文本
nextText: {
type: String,
default: '下一步'
},
skipText: {
type: String,
default: '跳过'
},
finishText: {
type: String,
default: '完成'
},
// 是否可以点击背景关闭
closeOnClickOutside: {
type: Boolean,
default: true
},
// 提示框偏移量
offset: {
type: Number,
default: 16
}
})
const emit = defineEmits(['update:modelValue', 'finish', 'skip', 'step-change'])
// 响应式数据
const currentStepIndex = ref(0)
const tooltipStyle = ref({})
const tooltipPosition = ref('bottom')
// 计算属性
const visible = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value)
})
const currentStepData = computed(() => {
return props.steps[currentStepIndex.value] || {}
})
// 添加高亮样式到目标元素
const addHighlight = targetSelector => {
removeAllHighlights()
const element = document.querySelector(targetSelector)
if (element) {
element.classList.add('guide-highlight')
element.style.position = 'relative'
element.style.zIndex = '1002'
}
}
// 移除所有高亮样式
const removeAllHighlights = () => {
const highlightedElements = document.querySelectorAll('.guide-highlight')
highlightedElements.forEach(el => {
el.classList.remove('guide-highlight')
el.style.zIndex = ''
})
}
// 计算提示框位置
const updateTooltipPosition = async() => {
await nextTick()
const targetSelector = currentStepData.value.target
const targetElement = document.querySelector(targetSelector)
if (!targetElement) {
console.warn(`Guide target not found: ${targetSelector}`)
return
}
// 添加高亮效果
addHighlight(targetSelector)
const rect = targetElement.getBoundingClientRect()
const windowHeight = window.innerHeight
const windowWidth = window.innerWidth
const tooltipWidth = 300
const tooltipHeight = 160
// 判断提示框应该显示在目标元素的哪个位置
const spaceBelow = windowHeight - rect.bottom
const spaceAbove = rect.top
const spaceRight = windowWidth - rect.right
const spaceLeft = rect.left
let position = 'bottom'
let top = rect.bottom + props.offset
let left = rect.left + rect.width / 2 - tooltipWidth / 2
// 垂直位置判断
if (spaceBelow < tooltipHeight && spaceAbove > tooltipHeight) {
position = 'top'
top = rect.top - tooltipHeight - props.offset
}
// 水平位置调整
if (left < 20) {
left = 20
} else if (left + tooltipWidth > windowWidth - 20) {
left = windowWidth - tooltipWidth - 20
}
tooltipPosition.value = position
tooltipStyle.value = {
top: top + 'px',
left: left + 'px',
maxWidth: tooltipWidth + 'px'
}
}
// 下一步
const handleNext = async() => {
if (currentStepIndex.value < props.steps.length - 1) {
currentStepIndex.value++
emit('step-change', currentStepIndex.value)
await updateTooltipPosition()
} else {
handleFinish()
}
}
// 跳过引导
const handleSkip = () => {
if (props.closeOnClickOutside) {
visible.value = false
removeAllHighlights()
emit('skip')
}
}
// 完成引导
const handleFinish = () => {
visible.value = false
removeAllHighlights()
emit('finish')
}
// 开始引导
const startGuide = async() => {
currentStepIndex.value = 0
await updateTooltipPosition()
}
// 监听显示状态变化
watch(visible, async newVal => {
if (newVal) {
await startGuide()
} else {
removeAllHighlights()
}
})
// 监听窗口大小变化
window.addEventListener('resize', updateTooltipPosition)
defineExpose({
startGuide,
nextStep: handleNext,
skipGuide: handleSkip,
finishGuide: handleFinish
})
</script>
<style scoped>
.guide-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.guide-tooltip {
position: absolute;
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
max-width: 300px;
min-width: 250px;
z-index: 1001;
border: 1px solid #e4e7ed;
}
.guide-tooltip::before {
content: '';
position: absolute;
width: 0;
height: 0;
border-style: solid;
}
.guide-tooltip.bottom::before {
top: -8px;
left: 50%;
transform: translateX(-50%);
border-width: 0 8px 8px 8px;
border-color: transparent transparent white transparent;
}
.guide-tooltip.top::before {
bottom: -8px;
left: 50%;
transform: translateX(-50%);
border-width: 8px 8px 0 8px;
border-color: white transparent transparent transparent;
}
.tooltip-content h4 {
margin: 0 0 12px 0;
color: #303133;
font-size: 16px;
font-weight: 600;
line-height: 1.4;
}
.tooltip-content p {
margin: 0 0 20px 0;
color: #606266;
font-size: 14px;
line-height: 1.6;
}
.tooltip-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
.step-indicator {
color: #909399;
font-size: 12px;
font-weight: 500;
}
.button-group {
display: flex;
gap: 8px;
}
.fade-enter-active, .fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
@media (max-width: 768px) {
.guide-tooltip {
max-width: calc(100vw - 40px);
min-width: auto;
margin: 20px;
}
}
</style>
<style>
.guide-highlight {
box-shadow: 0 0 0 4px rgba(64, 158, 255, 0.3) !important;
border-radius: 4px !important;
transition: box-shadow 0.3s ease !important;
}
</style>
集成方式
<!-- 1. 引入组件 -->
import UserGuide from './components/UserGuide.vue'
<!-- 2. 添加到模板 -->
<UserGuide
v-model="showGuide"
:steps="guideSteps"
@finish="onGuideFinish"
/>
<!-- 3. 配置 -->
const guideSteps = [
{
target: '#xxx', // CSS选择器
title: '按钮标题',
description: '按钮说明'
}
]
示例
<!-- 现有页面 -->
<template>
<div class="page-container">
<h1>我的应用页面</h1>
<!-- 现有内容 -->
<div class="content-section">
<div class="button-group">
<el-button
id="create-btn"
type="primary"
size="large"
@click="handleCreate"
>
<el-icon><Plus /></el-icon>
创建项目
</el-button>
<el-button
id="edit-btn"
type="success"
size="large"
@click="handleEdit"
>
<el-icon><Edit /></el-icon>
编辑内容
</el-button>
<el-button
id="delete-btn"
type="danger"
size="large"
@click="handleDelete"
>
<el-icon><Delete /></el-icon>
删除数据
</el-button>
</div>
<!-- 其他现有内容 -->
<div class="other-content">
<p>其他页面内容...</p>
<el-button @click="showGuide = true">
开始引导
</el-button>
</div>
</div>
<!-- 引导组件 -->
<UserGuide
v-model="showGuide"
:steps="guideSteps"
@finish="onGuideFinish"
@skip="onGuideSkip"
@step-change="onStepChange"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Edit, Delete } from '@element-plus/icons-vue'
import UserGuide from './components/UserGuide.vue' // 引入引导组件
// 控制引导显示
const showGuide = ref(false)
// 配置引导
const guideSteps = ref([
{
target: '#create-btn', // 目标元素选择器
title: '创建新项目',
description: '这是创建按钮。'
},
{
target: '#edit-btn',
title: '编辑项目内容',
description: '这是编辑按钮。'
},
{
target: '#delete-btn',
title: '删除项目数据',
description: '这是删除按钮。'
}
])
const handleCreate = () => {
ElMessage.success('创建项目功能')
}
const handleEdit = () => {
ElMessage.success('编辑内容功能')
}
const handleDelete = () => {
ElMessageBox.confirm('确定要删除吗?', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
ElMessage.success('删除成功!')
}).catch(() => {
ElMessage.info('已取消删除')
})
}
// 引导事件处理
const onGuideFinish = () => {
ElMessage.success('引导完成!')
// 避免重复引导
localStorage.setItem('guide_completed', 'true')
}
const onGuideSkip = () => {
ElMessage.info('已跳过引导')
}
const onStepChange = (stepIndex) => {
console.log('当前步骤:', stepIndex)
}
// 页面加载时检查是否需要显示引导
onMounted(() => {
const guideCompleted = localStorage.getItem('guide_completed')
if (!guideCompleted) {
// 延迟1秒后自动显示引导
setTimeout(() => {
showGuide.value = true
}, 1000)
}
})
</script>
<style scoped>
.page-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.content-section {
margin: 40px 0;
}
.button-group {
display: flex;
gap: 20px;
justify-content: center;
margin: 40px 0;
}
.other-content {
text-align: center;
margin-top: 60px;
padding: 40px;
background: #f9f9f9;
border-radius: 8px;
}
@media (max-width: 768px) {
.button-group {
flex-direction: column;
align-items: center;
}
}
</style>