602 lines
17 KiB
Vue
602 lines
17 KiB
Vue
<template>
|
||
<view v-if="isActive" class="guide-overlay" v-show="!isComponentMode">
|
||
<!-- 遮罩层:使用多个矩形拼接来挖洞 -->
|
||
<view v-if="stepConfig && stepConfig.mask !== false" class="guide-mask" @click.stop="handleMaskClick">
|
||
<!-- 高亮区域 -->
|
||
<view class="highlight-area" :style="highlightStyle" @click.stop="handleHighlightClick">
|
||
<!-- 可选:添加脉冲动画效果 -->
|
||
<!-- <view class="highlight-pulse"></view> -->
|
||
</view>
|
||
</view>
|
||
<!-- 无遮罩层时的高亮区域(仅点击区域) -->
|
||
<view v-else class="highlight-area-only" :style="highlightStyle" @click.stop="handleHighlightClick"></view>
|
||
|
||
<!-- 角色图片位置 -->
|
||
<view v-if="stepConfig && stepConfig.character" class="guide-character"
|
||
:style="[characterStyle, { zIndex: 10002 }]">
|
||
<image :src="`/static/guide/${stepConfig.character}.png`" mode="aspectFit" :style="{ width: `${stepConfig.characterSize}rpx`, height: `${stepConfig.characterSize}rpx` }" class="character-img" />
|
||
</view>
|
||
|
||
<!-- 提示气泡 -->
|
||
<view v-if="stepConfig" class="guide-tooltip"
|
||
:style="[tooltipStyle, stepConfig.dialogType ? { backgroundImage: `url(/static/guide/dialog_${stepConfig.dialogType}.png)` } : {}]"
|
||
@click.stop="handleTooltipClick">
|
||
<view class="tooltip-content">
|
||
<view v-if="stepConfig.image" class="tooltip-image-wrapper" :style="imageStyle">
|
||
<image :src="stepConfig.image" mode="aspectFit" class="tooltip-image" />
|
||
</view>
|
||
<text class="tooltip-text">
|
||
<text v-for="(part, index) in highlightedContent" :key="index"
|
||
:class="{ 'highlight-keyword': part.highlight }">{{ part.text }}</text>
|
||
</text>
|
||
</view>
|
||
<view class="tooltip-buttons">
|
||
<view v-if="stepConfig.buttons && stepConfig.buttons.includes('prev') && !isFirst" class="tooltip-btn prev-btn"
|
||
@click="handlePrev">
|
||
上一步
|
||
</view>
|
||
<view v-if="stepConfig.buttons && stepConfig.buttons.includes('skip') && isLast" class="tooltip-btn skip-btn"
|
||
@click="handleSkip">
|
||
跳过
|
||
</view>
|
||
<view v-if="stepConfig.buttons && stepConfig.buttons.includes('next')" class="tooltip-btn next-btn"
|
||
@click="handleNext">
|
||
{{ isLast ? '完成' : '下一步' }}
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, watch, onMounted, onUnmounted } from 'vue'
|
||
import { useStore } from 'vuex'
|
||
|
||
const store = useStore()
|
||
|
||
// 状态映射
|
||
const isActive = computed(() => {
|
||
const val = store.state.guide.isActive
|
||
console.log('[GuideOverlay] isActive computed:', val)
|
||
return val
|
||
})
|
||
const isComponentMode = computed(() => store.state.guide.componentMode)
|
||
const targetRect = computed(() => store.state.guide.targetRect)
|
||
const stepConfig = computed(() => store.getters['guide/currentStepConfig'])
|
||
const isFirst = computed(() => store.getters['guide/isFirst'])
|
||
const isLast = computed(() => store.getters['guide/isLast'])
|
||
|
||
// 监听引导激活状态
|
||
watch(() => store.state.guide.isActive, (newVal) => {
|
||
console.log('[GuideOverlay] isActive changed to:', newVal)
|
||
}, { immediate: true })
|
||
|
||
// 组件挂载时记录当前状态
|
||
onMounted(() => {
|
||
console.log('[GuideOverlay] mounted, isActive:', store.state.guide.isActive)
|
||
})
|
||
|
||
// 高亮区域样式
|
||
const highlightStyle = computed(() => {
|
||
const config = stepConfig.value || {}
|
||
const offset = config.offset || { x: 20, y: 16 }
|
||
|
||
// 如果配置了 highlightRect,直接使用写死的坐标
|
||
if (config.highlightRect) {
|
||
return {
|
||
top: `${config.highlightRect.top - offset.y}rpx`,
|
||
left: `${config.highlightRect.left - offset.x}rpx`,
|
||
width: `${config.highlightRect.width + offset.x * 2}rpx`,
|
||
height: `${config.highlightRect.height + offset.y * 2}rpx`
|
||
}
|
||
}
|
||
|
||
// 否则使用动态计算的坐标
|
||
const rect = targetRect.value
|
||
return {
|
||
top: `${rect.top - offset.y}rpx`,
|
||
left: `${rect.left - offset.x}rpx`,
|
||
width: `${rect.width + offset.x * 2}rpx`,
|
||
height: `${rect.height + offset.y * 2}rpx`
|
||
}
|
||
})
|
||
|
||
// 提示气泡样式
|
||
const tooltipStyle = computed(() => {
|
||
const rect = targetRect.value
|
||
const config = stepConfig.value || {}
|
||
const position = config.position || 'bottom'
|
||
const offset = config.offset || { x: 0, y: 0 }
|
||
const center = config.center !== false // 默认 true 居中
|
||
|
||
// 获取屏幕尺寸并转换为 rpx
|
||
const systemInfo = uni.getSystemInfoSync()
|
||
const rpxRatio = 750 / systemInfo.windowWidth
|
||
const screenHeight = systemInfo.windowHeight * rpxRatio // 屏幕高度 rpx
|
||
const screenWidth = systemInfo.windowWidth * rpxRatio // 屏幕宽度 rpx
|
||
const safeAreaTop = (systemInfo.safeAreaInsets?.top || 0) * rpxRatio
|
||
const safeAreaBottom = (systemInfo.safeAreaInsets?.bottom || 0) * rpxRatio
|
||
|
||
// 气泡高度估算(用于边界检测),支持自定义高度
|
||
const tooltipHeight = config.tooltipHeight || 300 // 默认 300rpx
|
||
|
||
let top, left
|
||
|
||
// 如果配置了居中,使用屏幕中心
|
||
if (center) {
|
||
top = screenHeight / 2
|
||
left = screenWidth / 2
|
||
} else if (config.customPosition) {
|
||
// 使用自定义坐标(已经是 rpx)
|
||
top = config.customPosition.y
|
||
left = config.customPosition.x
|
||
} else {
|
||
// 基于目标元素定位,智能选择上下方向
|
||
const targetCenterY = rect.top + rect.height / 2 // 目标元素中心 Y
|
||
const targetTop = rect.top // 目标元素顶部
|
||
const targetBottom = rect.top + rect.height // 目标元素底部
|
||
|
||
// 判断目标元素在屏幕的上半部分还是下半部分
|
||
const isTargetInUpperHalf = targetCenterY < screenHeight / 2
|
||
|
||
// 根据目标位置决定气泡显示在上方还是下方
|
||
let direction = position
|
||
if (position === 'bottom' || position === 'top') {
|
||
// 动态决定方向:目标在上半部分,气泡显示在下方;目标在下半部分,气泡显示在上方
|
||
direction = isTargetInUpperHalf ? 'bottom' : 'top'
|
||
}
|
||
|
||
switch (direction) {
|
||
case 'top':
|
||
// 气泡显示在目标元素上方
|
||
top = targetTop - offset.y - tooltipHeight / 2 - 20
|
||
left = rect.left + rect.width / 2
|
||
break
|
||
case 'bottom':
|
||
// 气泡显示在目标元素下方
|
||
top = targetBottom + offset.y + tooltipHeight / 2 + 20
|
||
left = rect.left + rect.width / 2
|
||
break
|
||
case 'left':
|
||
top = targetCenterY
|
||
left = rect.left - offset.x - 200
|
||
break
|
||
case 'right':
|
||
top = targetCenterY
|
||
left = rect.left + rect.width + offset.x + 200
|
||
break
|
||
default:
|
||
top = targetBottom + offset.y + tooltipHeight / 2 + 20
|
||
left = rect.left + rect.width / 2
|
||
}
|
||
|
||
// 边界检测:确保气泡不超出屏幕
|
||
const tooltipWidth = 480 // 气泡最大宽度 rpx
|
||
const minLeft = 20
|
||
const maxLeft = screenWidth - tooltipWidth - 20
|
||
|
||
// 左右边界检测
|
||
if (left < minLeft) left = minLeft
|
||
if (left > maxLeft) left = maxLeft
|
||
|
||
// 上下边界检测
|
||
if (top < safeAreaTop + 20) {
|
||
// 顶部空间不足,尝试放下面
|
||
top = targetBottom + offset.y + tooltipHeight / 2 + 40
|
||
}
|
||
if (top + tooltipHeight > screenHeight - safeAreaBottom - 20) {
|
||
// 底部空间不足,尝试放上面
|
||
top = targetTop - offset.y - tooltipHeight / 2 - 40
|
||
}
|
||
}
|
||
|
||
return {
|
||
top: `${top}rpx`,
|
||
left: `${left}rpx`,
|
||
transform: 'translate(-50%, -50%)',
|
||
...(config.tooltipHeight ? { height: `${config.tooltipHeight}rpx`, maxHeight: `${config.tooltipHeight}rpx` } : {})
|
||
}
|
||
})
|
||
|
||
// 角色图片样式(可自定义位置和尺寸)
|
||
const characterStyle = computed(() => {
|
||
const config = stepConfig.value || {}
|
||
const style = {}
|
||
|
||
if (config.characterPosition) {
|
||
style.top = `${config.characterPosition.y}rpx`
|
||
style.left = `${config.characterPosition.x}rpx`
|
||
style.transform = 'translate(-50%, -50%)'
|
||
} else {
|
||
// 默认:相对于气泡位置偏移
|
||
const tooltipTop = parseFloat(tooltipStyle.value.top)
|
||
const tooltipLeft = parseFloat(tooltipStyle.value.left)
|
||
const offsetX = config.characterOffsetX || 300
|
||
const offsetY = config.characterOffsetY || -100
|
||
style.top = `${tooltipTop + offsetY}rpx`
|
||
style.left = `${tooltipLeft + offsetX}rpx`
|
||
style.transform = 'translate(-50%, -50%)'
|
||
}
|
||
|
||
// 自定义尺寸
|
||
if (config.characterSize) {
|
||
style.width = `${config.characterSize}rpx`
|
||
style.height = `${config.characterSize}rpx`
|
||
}
|
||
|
||
return style
|
||
})
|
||
|
||
// 图片样式
|
||
const imageStyle = computed(() => {
|
||
const config = stepConfig.value || {}
|
||
const style = {}
|
||
if (config.imagePosition) {
|
||
style.marginLeft = `${config.imagePosition.x}rpx`
|
||
style.marginTop = `${config.imagePosition.y}rpx`
|
||
}
|
||
if (config.imageCenter) {
|
||
style.margin = '0 auto'
|
||
}
|
||
return style
|
||
})
|
||
|
||
// 高亮文字内容处理
|
||
const highlightedContent = computed(() => {
|
||
const config = stepConfig.value || {}
|
||
const content = config.content || ''
|
||
const keywords = config.highlightKeywords || []
|
||
|
||
if (!keywords.length) {
|
||
return [{ text: content, highlight: false }]
|
||
}
|
||
|
||
// 构建正则表达式,匹配所有关键词
|
||
const pattern = keywords.map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
|
||
const regex = new RegExp(`(${pattern})`, 'g')
|
||
const parts = content.split(regex)
|
||
|
||
return parts.map(part => ({
|
||
text: part,
|
||
highlight: keywords.includes(part)
|
||
}))
|
||
})
|
||
|
||
|
||
// 监听引导激活,刷新位置
|
||
watch(() => store.state.guide.isActive, async (newVal) => {
|
||
if (newVal) {
|
||
// 延迟执行,确保 stepConfig 已更新
|
||
setTimeout(() => {
|
||
calculatePosition()
|
||
}, 300)
|
||
}
|
||
})
|
||
|
||
// 监听步骤变化,重新计算位置
|
||
watch(() => store.state.guide.currentStep, async () => {
|
||
if (isActive.value) {
|
||
// 延迟执行,确保 stepConfig 已更新
|
||
setTimeout(() => {
|
||
calculatePosition()
|
||
}, 300)
|
||
}
|
||
})
|
||
|
||
// 组件挂载时初始化
|
||
onMounted(() => {
|
||
console.log('[GuideOverlay] mounted, isActive:', store.state.guide.isActive, 'currentGuide:', store.state.guide.currentGuide?.key, 'currentStep:', store.state.guide.currentStep)
|
||
if (isActive.value) {
|
||
// 延迟执行,确保 DOM 已渲染完成
|
||
setTimeout(() => {
|
||
calculatePosition()
|
||
}, 500)
|
||
}
|
||
// 监听 cabin 数据加载完成事件,触发重新计算位置
|
||
uni.$on('guide:recalculatePosition', () => {
|
||
if (isActive.value) {
|
||
setTimeout(() => {
|
||
calculatePosition()
|
||
}, 100)
|
||
}
|
||
})
|
||
// 监听组件关闭事件,关闭后执行下一步
|
||
let closeComponentHandled = false // 防止重复处理的标志
|
||
uni.$on('guide:closeComponent', () => {
|
||
console.log('[GuideOverlay] 收到 guide:closeComponent 事件, componentMode:', store.state.guide.componentMode)
|
||
// 如果已经处理过或者 componentMode 已经是 false,忽略
|
||
if (closeComponentHandled || !store.state.guide.componentMode) {
|
||
console.log('[GuideOverlay] guide:closeComponent 忽略, closeComponentHandled:', closeComponentHandled, 'componentMode:', store.state.guide.componentMode)
|
||
return
|
||
}
|
||
closeComponentHandled = true
|
||
store.commit('guide/RESET_COMPONENT_MODE')
|
||
// component action 后需要手动前进到下一步
|
||
if (store.getters['guide/hasNext']) {
|
||
console.log('[GuideOverlay] 有下一步,前进')
|
||
store.commit('guide/NEXT_STEP')
|
||
} else {
|
||
console.log('[GuideOverlay] 没有下一步,结束引导')
|
||
store.commit('guide/END_GUIDE')
|
||
}
|
||
// 重置标志,允许未来再次处理
|
||
setTimeout(() => {
|
||
closeComponentHandled = false
|
||
}, 500)
|
||
})
|
||
})
|
||
|
||
// 计算目标元素位置(带重试)
|
||
async function calculatePosition(retryCount = 0) {
|
||
const config = stepConfig.value
|
||
const selector = config?.target
|
||
|
||
// 如果配置了 highlightRect,直接使用写死的坐标,不需要查询元素
|
||
if (config?.highlightRect) {
|
||
store.commit('guide/SET_TARGET_RECT', config.highlightRect)
|
||
return config.highlightRect
|
||
}
|
||
|
||
if (!selector) return
|
||
|
||
const maxRetries = 5 // 增加重试次数
|
||
const result = await store.dispatch('guide/calculateTargetPosition', selector)
|
||
|
||
// 如果没找到元素且还有重试次数,延迟后重试
|
||
if (!result && retryCount < maxRetries) {
|
||
await new Promise(resolve => setTimeout(resolve, 500)) // 增加重试间隔
|
||
return calculatePosition(retryCount + 1)
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
// 点击遮罩(高亮区域外)
|
||
function handleMaskClick() {
|
||
// 可选:播放提示音效或震动
|
||
uni.vibrateShort({ fail: () => { } })
|
||
}
|
||
|
||
// 点击气泡继续下一步(当 mask: false 且无按钮时)
|
||
function handleTooltipClick() {
|
||
const config = stepConfig.value
|
||
if (!config) return
|
||
|
||
// 如果有按钮,不处理气泡点击
|
||
if (config.buttons && config.buttons.length > 0) return
|
||
|
||
// 如果有高亮区域,不处理气泡点击
|
||
if (config.mask !== false) return
|
||
|
||
// 无高亮无按钮时,点击气泡继续下一步
|
||
handleNext()
|
||
}
|
||
|
||
// 点击高亮区域
|
||
function handleHighlightClick() {
|
||
const stepConfigVal = stepConfig.value
|
||
console.log('[GuideOverlay] handleHighlightClick, stepConfig:', stepConfigVal)
|
||
if (stepConfigVal && stepConfigVal.action) {
|
||
const { action } = stepConfigVal
|
||
console.log('[GuideOverlay] action:', action)
|
||
|
||
if (action.type === 'navigate' && action.url) {
|
||
// 路由跳转
|
||
// 先设置 isNavigating 标志,防止 nextStep 重复发起导航
|
||
store.commit('guide/SET_NAVIGATING', true)
|
||
uni.navigateTo({
|
||
url: action.url,
|
||
success: () => {
|
||
// 跳转成功后执行下一步
|
||
// isNavigating 已在 handleNext -> nextStep 中处理
|
||
handleNext()
|
||
},
|
||
fail: (err) => {
|
||
console.error('[Guide] 页面跳转失败:', err)
|
||
store.commit('guide/SET_NAVIGATING', false)
|
||
}
|
||
})
|
||
} else if (action.type === 'component' && action.name) {
|
||
// 打开组件(通过事件通知父组件)
|
||
// 先标记当前步骤完成,关闭遮罩
|
||
store.commit('guide/COMPLETE_SUBSTEP')
|
||
store.commit('guide/SET_COMPONENT_MODE')
|
||
// 通知父组件打开组件
|
||
store.dispatch('guide/openComponent', action.name)
|
||
// 组件打开后会通过 guide:closeComponent 事件通知关闭
|
||
} else if (action.type === 'function' && action.handler) {
|
||
// 执行函数
|
||
store.dispatch('guide/executeFunction', action.handler)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 上一步
|
||
function handlePrev() {
|
||
store.dispatch('guide/prevStep')
|
||
}
|
||
|
||
// 下一步
|
||
function handleNext() {
|
||
store.dispatch('guide/nextStep')
|
||
}
|
||
|
||
// 跳过
|
||
function handleSkip() {
|
||
store.dispatch('guide/skipGuide')
|
||
}
|
||
|
||
// 页面卸载时清理
|
||
onUnmounted(() => {
|
||
uni.$off('guide:recalculatePosition')
|
||
})
|
||
|
||
// 暴露方法供外部调用
|
||
defineExpose({
|
||
calculatePosition
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.guide-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
z-index: 10000;
|
||
pointer-events: auto;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* 遮罩层:使用伪元素创建半透明背景 */
|
||
.guide-mask {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0);
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* 高亮区域:透明显示 */
|
||
.highlight-area {
|
||
position: absolute;
|
||
background: transparent;
|
||
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.8);
|
||
pointer-events: auto;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
/* 无遮罩层时的高亮区域 */
|
||
.highlight-area-only {
|
||
position: absolute;
|
||
background: transparent;
|
||
pointer-events: auto;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
/* 脉冲动画效果 */
|
||
.highlight-pulse {
|
||
position: absolute;
|
||
top: -8rpx;
|
||
left: -8rpx;
|
||
right: -8rpx;
|
||
bottom: -8rpx;
|
||
}
|
||
|
||
/* 提示气泡 */
|
||
.guide-tooltip {
|
||
position: absolute;
|
||
transform: translate(-50%, -50%);
|
||
min-width: 464rpx;
|
||
max-width: 560rpx;
|
||
/* max-height: 192rpx; */
|
||
/* background: #fff; */
|
||
background-size: cover;
|
||
background-repeat: no-repeat;
|
||
border-radius: 24rpx;
|
||
padding: 32rpx;
|
||
/* box-shadow: 0 8rpx 40rpx rgba(0, 0, 0, 0.3); */
|
||
pointer-events: auto;
|
||
z-index: 10001;
|
||
display: flex;
|
||
align-items: center;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.tooltip-content {
|
||
margin-bottom: 24rpx;
|
||
width: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
}
|
||
|
||
.tooltip-image-wrapper {
|
||
margin-top: 16rpx;
|
||
margin-bottom: 16rpx;
|
||
display: flex;
|
||
justify-content: center;
|
||
}
|
||
|
||
.tooltip-image {
|
||
max-width: 300rpx;
|
||
max-height: 200rpx;
|
||
}
|
||
|
||
.highlight-keyword {
|
||
color: #FFA500;
|
||
}
|
||
|
||
/* 背景图片层 */
|
||
.guide-bg-image {
|
||
position: absolute;
|
||
transform: translate(-50%, -50%);
|
||
pointer-events: none;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.bg-image-img {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
/* 角色图片层 */
|
||
.guide-character {
|
||
position: absolute;
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.tooltip-text {
|
||
font-size: 32rpx;
|
||
color: #fff;
|
||
line-height: 1.5;
|
||
white-space: pre-line;
|
||
text-align: center;
|
||
}
|
||
|
||
.tooltip-buttons {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 24rpx;
|
||
}
|
||
|
||
.tooltip-btn {
|
||
padding: 16rpx 40rpx;
|
||
border-radius: 40rpx;
|
||
font-size: 28rpx;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.prev-btn {
|
||
background: #f0f0f0;
|
||
color: #666;
|
||
}
|
||
|
||
.prev-btn:active {
|
||
background: #e0e0e0;
|
||
}
|
||
|
||
.skip-btn {
|
||
background: #f0f0f0;
|
||
color: #666;
|
||
}
|
||
|
||
.skip-btn:active {
|
||
background: #e0e0e0;
|
||
}
|
||
|
||
.next-btn {
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: #fff;
|
||
}
|
||
|
||
.next-btn:active {
|
||
opacity: 0.9;
|
||
transform: scale(0.98);
|
||
}
|
||
</style>
|