452 lines
14 KiB
Vue
452 lines
14 KiB
Vue
<template>
|
||
<view class="wd-tour" v-if="modelValue" :style="rootStyle" @touchmove.stop.prevent="noop">
|
||
<view class="wd-tour__mask" @click.stop="handleMask">
|
||
<slot name="highlight" :elementInfo="highlightElementInfo">
|
||
<view :class="highlightClass" :style="highlightStyle"></view>
|
||
</slot>
|
||
<view class="wd-tour__popover" :style="popoverStyle">
|
||
<slot name="content">
|
||
<view class="wd-tour__info">
|
||
<rich-text :nodes="currentStep.content"></rich-text>
|
||
</view>
|
||
</slot>
|
||
|
||
<view class="wd-tour__buttons" v-if="showTourButtons">
|
||
<!-- 上一步按钮 -->
|
||
<view class="wd-tour__prev" v-if="currentIndex > 0" @click.stop="handlePrev">
|
||
<slot name="prev">
|
||
<view class="wd-tour__prev__default">{{ prevText }}</view>
|
||
</slot>
|
||
</view>
|
||
|
||
<!-- 跳过按钮 -->
|
||
<view class="wd-tour__skip" @click.stop="handleSkip">
|
||
<slot name="skip" v-if="$slots.skip"></slot>
|
||
<view class="wd-tour__skip__default" v-else>{{ skipText }}</view>
|
||
</view>
|
||
|
||
<!-- 下一步按钮 -->
|
||
<view class="wd-tour__next" v-if="currentIndex !== steps.length - 1" @click.stop="handleNext">
|
||
<slot name="next">
|
||
<view class="wd-tour__next__default">
|
||
{{ `${nextText}(${currentIndex + 1}/${steps.length})` }}
|
||
</view>
|
||
</slot>
|
||
</view>
|
||
|
||
<!-- 完成按钮 -->
|
||
<view class="wd-tour__finish" v-if="currentIndex === steps.length - 1" @click.stop="handleFinish">
|
||
<slot name="finish">
|
||
<view class="wd-tour__finish__default">{{ finishText }}</view>
|
||
</slot>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script lang="ts">
|
||
export default {
|
||
name: 'wd-tour',
|
||
options: {
|
||
addGlobalClass: true,
|
||
virtualHost: true,
|
||
styleIsolation: 'shared'
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<script lang="ts" setup>
|
||
import { ref, computed, watch, nextTick, type CSSProperties } from 'vue'
|
||
import { addUnit, getRect, getSystemInfo, isDef, objToStyle } from '../common/util'
|
||
import { tourProps, type TourEmits } from './types'
|
||
import { useRaf } from '../composables/useRaf'
|
||
import { useTranslate } from '../composables/useTranslate'
|
||
|
||
const props = defineProps(tourProps)
|
||
const emit = defineEmits<TourEmits>()
|
||
const { translate } = useTranslate('tour')
|
||
|
||
const prevText = computed(() => {
|
||
return isDef(props.prevText) ? props.prevText : translate('prev')
|
||
})
|
||
|
||
const nextText = computed(() => {
|
||
return isDef(props.nextText) ? props.nextText : translate('next')
|
||
})
|
||
|
||
const skipText = computed(() => {
|
||
return isDef(props.skipText) ? props.skipText : translate('skip')
|
||
})
|
||
|
||
const finishText = computed(() => {
|
||
return isDef(props.finishText) ? props.finishText : translate('finish')
|
||
})
|
||
|
||
const currentIndex = ref<number>(0) // 当前步骤索引
|
||
const elementInfo = ref<UniApp.NodeInfo>({
|
||
top: 0,
|
||
left: 0,
|
||
width: 0,
|
||
height: 0
|
||
}) // 元素信息
|
||
const windowHeight = ref<number>(0) // 窗口高度
|
||
const windowTop = ref<number>(0) // 窗口顶部位置
|
||
const isElementInTop = ref<boolean>(true) // 判断元素位置,确定提示信息在该元素的上方还是下方,true为上方,false为下方
|
||
const lastScrollTop = ref<number>(0) //记录上一次滚动位置
|
||
const statusBarHeight = ref<number>(0) // 状态栏高度
|
||
const menuButtonInfo = ref(null as UniNamespace.GetMenuButtonBoundingClientRectRes | null)
|
||
const topOffset = ref<number>(0) // 顶部偏移量
|
||
|
||
const rootStyle = computed(() => {
|
||
const style: CSSProperties = {}
|
||
if (isDef(props.zIndex)) {
|
||
style.zIndex = props.zIndex
|
||
}
|
||
return objToStyle(style)
|
||
})
|
||
|
||
const highlightClass = computed(() => {
|
||
return `wd-tour__highlight ${props.mask ? 'wd-tour__highlight--mask' : ''}`
|
||
})
|
||
|
||
// 计算属性
|
||
const currentStep = computed(() => {
|
||
return props.steps[currentIndex.value] || {}
|
||
})
|
||
// 提取公共的默认样式函数
|
||
function getDefaultStyle(): CSSProperties {
|
||
return {
|
||
transition: props.duration + 'ms all'
|
||
}
|
||
}
|
||
// 提取公共的高亮样式计算函数
|
||
function calculateHighlightStyle(padding: number): CSSProperties {
|
||
return {
|
||
transition: props.duration + 'ms all,boxShadow 0s,height 0s,width 0s',
|
||
borderRadius: props.borderRadius + 'px',
|
||
padding: padding + 'px'
|
||
}
|
||
}
|
||
const highlightStyle = computed(() => {
|
||
// 如果元素信息尚未获取到,返回空样式避免闪烁
|
||
if (!elementInfo.value.width && !elementInfo.value.height) {
|
||
return getDefaultStyle()
|
||
}
|
||
const stepPadding = Number(isDef(currentStep.value.padding) ? currentStep.value.padding : props.padding)
|
||
const baseStyle = calculateHighlightStyle(stepPadding)
|
||
const style: CSSProperties = {
|
||
...baseStyle,
|
||
top: addUnit((elementInfo.value.top || 0) - stepPadding),
|
||
left: addUnit((elementInfo.value.left || 0) - stepPadding),
|
||
height: addUnit(elementInfo.value.height || 0),
|
||
width: addUnit(elementInfo.value.width || 0)
|
||
}
|
||
if (isDef(props.mask) && isDef(props.maskColor)) {
|
||
style.boxShadow = `0 0 0 100vh ${props.maskColor}`
|
||
}
|
||
return objToStyle([{ ...style }, props.highlightStyle])
|
||
})
|
||
|
||
const popoverStyle = computed(() => {
|
||
const style: CSSProperties = {}
|
||
if (isDef(props.zIndex)) {
|
||
const zIndex = Number(props.zIndex)
|
||
style.zIndex = zIndex + 1
|
||
style.transitionDuration = `${props.duration}ms`
|
||
}
|
||
const stepPadding = Number(isDef(currentStep.value.offset) ? currentStep.value.offset : props.offset)
|
||
const placement = isDef(currentStep.value.placement) ? currentStep.value.placement : 'auto'
|
||
const down = placement === 'bottom' || (placement === 'auto' && isElementInTop.value)
|
||
if (down) {
|
||
// 提示在元素下方
|
||
style.top = addUnit((elementInfo.value.top || 0) + (elementInfo.value.height || 0) + Number(stepPadding))
|
||
} else {
|
||
// 提示在元素上方
|
||
style.bottom = addUnit(windowHeight.value + windowTop.value - (elementInfo.value.top || 0) + Number(stepPadding))
|
||
}
|
||
|
||
return objToStyle(style)
|
||
})
|
||
|
||
const highlightElementInfo = computed(() => {
|
||
const stepPadding = Number(isDef(currentStep.value.padding) ? currentStep.value.padding : props.padding)
|
||
// 如果元素信息尚未获取到,返回空样式避免闪烁
|
||
if (!elementInfo.value.width && !elementInfo.value.height) {
|
||
return getDefaultStyle()
|
||
}
|
||
|
||
const baseStyle = calculateHighlightStyle(stepPadding)
|
||
const style: CSSProperties = {
|
||
...baseStyle,
|
||
top: addUnit((elementInfo.value.top || 0) - stepPadding),
|
||
left: addUnit((elementInfo.value.left || 0) - stepPadding),
|
||
width: addUnit((elementInfo.value.width || 0) + stepPadding * 2),
|
||
height: addUnit((elementInfo.value.height || 0) + stepPadding * 2)
|
||
}
|
||
if (isDef(props.mask) && isDef(props.maskColor)) {
|
||
style.boxShadow = `0 0 0 100vh ${props.maskColor}`
|
||
}
|
||
return style
|
||
})
|
||
function noop() {}
|
||
// 方法
|
||
async function updateElementInfo() {
|
||
updateSystemInfo()
|
||
const element = currentStep.value.element
|
||
if (!element) return
|
||
try {
|
||
const res = (await getRect(element, false, props.scope)) as UniApp.NodeInfo
|
||
initializeElementInfo(res)
|
||
const effectiveBoundaries = getEffectiveBoundaries()
|
||
const scrollNeeds = checkScrollNeeds(res, effectiveBoundaries)
|
||
handleScrolling(res, scrollNeeds, effectiveBoundaries)
|
||
calculateTipPosition(res)
|
||
} catch (error) {
|
||
console.error('updateElementInfo error:', error)
|
||
emit('error', {
|
||
message: '无法找到指定的引导元素',
|
||
element: element
|
||
})
|
||
if (props.missingStrategy === 'skip') {
|
||
handleNext()
|
||
} else if (props.missingStrategy === 'hide') {
|
||
emit('update:modelValue', false)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 更新系统信息
|
||
function updateSystemInfo() {
|
||
const sysInfo = getSystemInfo()
|
||
windowHeight.value = sysInfo.windowHeight
|
||
windowTop.value = sysInfo.windowTop || 0
|
||
statusBarHeight.value = sysInfo.statusBarHeight || 0
|
||
}
|
||
|
||
// 初始化元素信息
|
||
function initializeElementInfo(res: UniApp.NodeInfo) {
|
||
elementInfo.value = res
|
||
// 调整元素位置信息,加上窗口顶部偏移量
|
||
elementInfo.value.top = (res.top || 0) + windowTop.value
|
||
elementInfo.value.bottom = ((res.bottom !== undefined ? res.bottom : (res.top || 0) + (res.height || 0)) as number) + windowTop.value
|
||
}
|
||
// 获取有效的页面边界(顶部和底部安全区域)
|
||
function getEffectiveBoundaries() {
|
||
// 有效顶部边界初始化为窗口顶部 + 顶部偏移量
|
||
let effectiveWindowTop = windowTop.value + Number(topOffset.value)
|
||
// 有效底部边界为窗口高度
|
||
let effectiveWindowBottom = windowHeight.value
|
||
return {
|
||
top: effectiveWindowTop,
|
||
bottom: effectiveWindowBottom
|
||
}
|
||
}
|
||
// 检查是否需要滚动
|
||
function checkScrollNeeds(res: UniApp.NodeInfo, boundaries: { top: number; bottom: number }) {
|
||
// 判断元素是否被顶部遮挡(需要向上滚动)
|
||
const needScrollUp = Number(res.top) < boundaries.top
|
||
// 判断元素是否被底部遮挡(需要向下滚动)
|
||
const needScrollDown = (res.bottom !== undefined ? res.bottom : 0) + Number(props.bottomSafetyOffset) > boundaries.bottom
|
||
return {
|
||
up: needScrollUp, //提示框往上走
|
||
down: needScrollDown //提示框往下走
|
||
}
|
||
}
|
||
|
||
// 处理滚动逻辑
|
||
function handleScrolling(res: UniApp.NodeInfo, scrollNeeds: { up: boolean; down: boolean }, boundaries: { top: number; bottom: number }) {
|
||
if (scrollNeeds.up) {
|
||
// 元素被顶部遮挡,需要提示框往上走,页面往下走
|
||
scrollUp(res, boundaries)
|
||
} else if (scrollNeeds.down) {
|
||
// 元素被底部遮挡,需要提示框向下走,页面向上走
|
||
scrollDown(res)
|
||
}
|
||
}
|
||
|
||
// 向引导上滚动处理
|
||
function scrollUp(res: UniApp.NodeInfo, boundaries: { top: number; bottom: number }) {
|
||
// 计算需要滚动的距离
|
||
let scrollDistance = lastScrollTop.value + Number(res.top) - props.padding - boundaries.top
|
||
// 更新元素位置信息(滚动后)
|
||
elementInfo.value.top = boundaries.top + props.padding
|
||
elementInfo.value.bottom = windowHeight.value - (boundaries.top + props.padding)
|
||
uni.pageScrollTo({
|
||
scrollTop: scrollDistance,
|
||
duration: Number(props.duration),
|
||
success: () => {
|
||
// 更新已滚动距离
|
||
lastScrollTop.value = scrollDistance
|
||
}
|
||
})
|
||
}
|
||
|
||
// 引导向下滚动处理
|
||
function scrollDown(res: UniApp.NodeInfo) {
|
||
// 计算需要滚动的距离
|
||
const bottom = res.bottom || 0
|
||
let scrollDistance = bottom - windowHeight.value + props.padding + Number(props.bottomSafetyOffset)
|
||
|
||
// 更新元素位置信息(滚动后)
|
||
elementInfo.value.top = windowHeight.value - bottom - props.padding - Number(props.bottomSafetyOffset) // 应该是减去安全偏移量
|
||
elementInfo.value.bottom = windowHeight.value - props.padding - Number(props.bottomSafetyOffset)
|
||
|
||
uni.pageScrollTo({
|
||
scrollTop: scrollDistance + lastScrollTop.value,
|
||
duration: Number(props.duration),
|
||
success: () => {
|
||
// 更新已滚动距离
|
||
lastScrollTop.value = scrollDistance + lastScrollTop.value
|
||
}
|
||
})
|
||
}
|
||
|
||
// 计算提示框显示位置(上方或下方)
|
||
function calculateTipPosition(res: UniApp.NodeInfo) {
|
||
// 计算导航区域总高度
|
||
let totalNavHeight = statusBarHeight.value
|
||
// 计算屏幕中心点位置
|
||
const screenCenter = (windowHeight.value + totalNavHeight) / 2 + windowTop.value
|
||
|
||
// 计算元素中心点位置
|
||
const elementCenter = (res.top || 0) + (res.height || 0) / 2 + windowTop.value
|
||
|
||
// 根据元素位置决定提示框显示在上方还是下方
|
||
if (elementCenter < screenCenter) {
|
||
isElementInTop.value = true
|
||
} else {
|
||
isElementInTop.value = false
|
||
}
|
||
}
|
||
|
||
function handlePrev() {
|
||
if (currentIndex.value > 0) {
|
||
const oldIndex = currentIndex.value
|
||
currentIndex.value--
|
||
emit('prev', {
|
||
prevCurrent: oldIndex,
|
||
current: currentIndex.value,
|
||
total: props.steps.length,
|
||
isElementInTop: isElementInTop.value
|
||
})
|
||
emit('change', { current: currentIndex.value })
|
||
}
|
||
}
|
||
|
||
function handleNext() {
|
||
if (currentIndex.value < props.steps.length - 1) {
|
||
const oldIndex = currentIndex.value
|
||
currentIndex.value++
|
||
emit('next', {
|
||
prevCurrent: oldIndex,
|
||
current: currentIndex.value,
|
||
total: props.steps.length,
|
||
isElementInTop: isElementInTop.value
|
||
})
|
||
emit('change', { current: currentIndex.value })
|
||
} else {
|
||
handleFinish()
|
||
}
|
||
}
|
||
|
||
function handleFinish() {
|
||
emit('finish', {
|
||
current: currentIndex.value,
|
||
total: props.steps.length
|
||
})
|
||
currentIndex.value = 0
|
||
lastScrollTop.value = 0 // 重置滚动位置
|
||
emit('update:modelValue', false)
|
||
}
|
||
|
||
function handleSkip() {
|
||
emit('skip', {
|
||
current: currentIndex.value,
|
||
total: props.steps.length
|
||
})
|
||
currentIndex.value = 0
|
||
lastScrollTop.value = 0 // 重置滚动位置
|
||
emit('update:modelValue', false)
|
||
}
|
||
function handleMask() {
|
||
if (props.clickMaskNext) {
|
||
handleNext()
|
||
}
|
||
}
|
||
|
||
watch(
|
||
() => props.current,
|
||
(newVal) => {
|
||
currentIndex.value = newVal
|
||
}
|
||
)
|
||
// 监听 currentIndex 变化,同步到父组件
|
||
watch(
|
||
() => currentIndex.value,
|
||
(newVal) => {
|
||
const raf = useRaf(updateElementInfo)
|
||
nextTick(() => {
|
||
raf.start()
|
||
})
|
||
emit('update:current', newVal)
|
||
}
|
||
)
|
||
|
||
// 监听 modelValue 变化,当组件显示时更新系统信息
|
||
watch(
|
||
() => props.modelValue,
|
||
(newVal) => {
|
||
if (newVal) {
|
||
// 组件显示时重置滚动位置并更新系统信息
|
||
lastScrollTop.value = 0
|
||
updateSystemInfo()
|
||
const raf = useRaf(() => {
|
||
updateElementInfo()
|
||
emit('update:current', currentIndex.value)
|
||
})
|
||
nextTick(() => {
|
||
raf.start()
|
||
})
|
||
}
|
||
},
|
||
{
|
||
immediate: true
|
||
}
|
||
)
|
||
|
||
// 所有平台统一处理逻辑
|
||
if (props.customNav) {
|
||
// 开启了自定义导航栏
|
||
if (props.topSafetyOffset && Number(props.topSafetyOffset) > 0) {
|
||
// 用户传入了顶部安全偏移量,优先使用用户设置的值
|
||
topOffset.value = Number(props.topSafetyOffset)
|
||
} else {
|
||
// 未传入顶部偏移量
|
||
// #ifdef MP
|
||
// 微信小程序平台获取菜单按钮信息并使用其顶部位置
|
||
menuButtonInfo.value = uni.getMenuButtonBoundingClientRect() || null
|
||
topOffset.value = menuButtonInfo.value ? menuButtonInfo.value.top : 0
|
||
// #endif
|
||
// #ifndef MP
|
||
// 非微信小程序平台默认为0
|
||
topOffset.value = 0
|
||
// #endif
|
||
}
|
||
} else {
|
||
// 未开启自定义导航栏,直接使用用户传入的顶部安全偏移量
|
||
topOffset.value = Number(props.topSafetyOffset) || 0
|
||
}
|
||
defineExpose({
|
||
handlePrev,
|
||
handleNext,
|
||
handleFinish,
|
||
handleSkip
|
||
})
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
@import './index.scss';
|
||
</style>
|