topfans/frontend/components/GuideOverlay.vue
2026-04-07 23:08:49 +08:00

580 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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(() => store.state.guide.isActive)
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'])
// 高亮区域样式
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(() => {
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', () => {
// 如果已经处理过或者 componentMode 已经是 false忽略
if (closeComponentHandled || !store.state.guide.componentMode) {
return
}
closeComponentHandled = true
store.commit('guide/RESET_COMPONENT_MODE')
// component action 后需要手动前进到下一步
if (store.getters['guide/hasNext']) {
store.commit('guide/NEXT_STEP')
} else {
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
if (stepConfigVal && stepConfigVal.action) {
const { action } = stepConfigVal
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;
}
.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>