Files
threeonecheck_web/node_modules/wot-design-uni/components/wd-tour/wd-tour.vue
2026-06-03 10:16:37 +08:00

452 lines
14 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 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>