这一版本优化了很多

This commit is contained in:
王利强
2026-06-03 10:16:37 +08:00
parent 8046316216
commit 2af9f1fd59
954 changed files with 58194 additions and 1609 deletions

View File

@@ -0,0 +1,145 @@
@import './../common/abstracts/_mixin.scss';
@import './../common/abstracts/variable.scss';
.wot-theme-dark {
@include b(tour) {
@include e(popover) {
background: $-dark-background2;
}
@include e(info) {
background: $-dark-background2;
border-color: $-dark-background2;
color: $-dark-color;
}
@include e(skip) {
color: $-dark-color;
}
@include e(highlight) {
@include m(mask) {
box-shadow: 0 0 0 100vh rgba(0, 0, 0, 0.7);
}
}
}
}
@include b(tour) {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
z-index: $-tour-z-index;
@include e(mask) {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
@include e(highlight) {
position: fixed;
background: transparent;
box-sizing: content-box;
animation: tour-show $-tour-animation-duration $-tour-highlight-animation-timing;
top: 0;
left: 0;
width: 100vw;
height: 0;
border-radius: 0;
padding: 0;
@include m(mask) {
box-shadow: 0 0 0 100vh $-tour-highlight-shadow-color;
}
}
@include e(popover) {
z-index: $-tour-popover-z-index;
position: fixed;
left: 50%;
transform: translateX(-50%);
max-width: $-tour-popover-max-width;
text-align: center;
transition: $-tour-animation-duration all;
background-color: $-tour-popover-bg;
padding: $-tour-popover-padding;
border-radius: $-tour-popover-radius;
width: fit-content;
min-width: $-tour-popover-min-width;
}
@include e(info) {
font-size: $-tour-popover-info-font-size;
background: $-tour-info-bg;
border: $-tour-info-border-width solid $-tour-info-border-color;
color: $-tour-info-text-color;
width: fit-content;
text-align: left;
}
@include e(buttons) {
display: flex;
justify-content: flex-end;
align-items: center;
padding-top: 6px;
color: $-tour-button-text-color;
}
@include e(prev) {
@include edeep(default) {
font-size: $-tour-button-font-size;
border-radius: $-tour-button-radius;
padding: $-tour-button-padding;
white-space: nowrap;
}
}
@include e(next) {
@include edeep(default) {
font-size: $-tour-button-font-size;
border-radius: $-tour-button-radius;
padding: $-tour-button-padding;
background: $-tour-primary-button-bg-color;
color: $-tour-primary-button-text-color;
white-space: nowrap;
}
}
@include e(finish) {
@include edeep(default) {
font-size: $-tour-button-font-size;
border-radius: $-tour-button-radius;
padding: $-tour-button-padding;
background: $-tour-primary-button-bg-color;
color: $-tour-primary-button-text-color;
white-space: nowrap;
}
}
@include e(skip) {
@include edeep(default) {
font-size: $-tour-button-font-size;
border-radius: $-tour-button-radius;
padding: $-tour-button-padding;
white-space: nowrap;
}
}
}
@keyframes tour-show {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}

238
node_modules/wot-design-uni/components/wd-tour/types.ts generated vendored Normal file
View File

@@ -0,0 +1,238 @@
import type { CSSProperties, PropType } from 'vue'
import { baseProps, makeBooleanProp, makeNumberProp, makeStringProp, makeArrayProp } from '../common/props'
export type MissingStrategy = 'skip' | 'stop' | 'hide'
export interface TourStep {
/**
* 需要高亮的元素选择器
*/
element: string
/**
* 引导文字内容
*/
content: string
/** 覆盖当前步骤的内边距 */
padding?: number
/** 覆盖当前步骤的提示与高亮间距 */
offset?: number
/** 强制提示位置 */
placement?: 'auto' | 'top' | 'bottom' | 'left' | 'right'
}
export const tourProps = {
...baseProps,
/**
* 是否显示引导组件,使用 v-model 绑定
* 类型boolean
* 默认值false
*/
modelValue: makeBooleanProp(false),
/**
* 引导步骤列表
* 类型array
* 默认值:[]
*/
steps: makeArrayProp<TourStep>(),
/**
* 引导框的current
* 类型number
* 默认值0
*/
current: makeNumberProp(0),
/**
* 蒙版是否显示
* 类型boolean
* 默认值true
*/
mask: makeBooleanProp(true),
/**
* 蒙版颜色(支持 rgba 格式)
* 类型string
*/
maskColor: String,
/**
* 引导框与高亮元素之间的间距,单位 px
* 类型number
* 默认值20
*/
offset: makeNumberProp(20),
/**
* 动画持续时间(毫秒)
* 类型number
* 默认值300
*/
duration: makeNumberProp(300),
/**
* 高亮区域的圆角大小
* 类型number
* 默认值8
*/
borderRadius: makeNumberProp(8),
/**
* 高亮区域的内边距
* 类型number
* 默认值8
*/
padding: makeNumberProp(8),
/**
* 上一步按钮文字
*/
prevText: String,
/**
* 下一步按钮文字
*/
nextText: String,
/**
* 跳过按钮文字
*/
skipText: String,
/**
* 完成按钮文字
*/
finishText: String,
/**
* 安全偏移量,用于滚动计算时确保元素周围有足够的空间
* 类型number
* 默认值100
*/
bottomSafetyOffset: makeNumberProp(100),
/**
* 顶部安全偏移量,用于滚动计算时确保元素周围有足够的空间
* 类型number
* 默认值0
*/
topSafetyOffset: makeNumberProp(0),
/**
* 是否自定义顶部导航栏
* 类型boolean
* 默认值false
*/
customNav: makeBooleanProp(false),
/**
* 点击蒙版是否可以下一步
* 类型boolean
* 默认值false
*/
clickMaskNext: makeBooleanProp(false),
/**
* 高亮区域样式
* 类型object
* 默认值:{}
*/
highlightStyle: {
type: Object as PropType<CSSProperties>,
default: () => ({})
},
/**
* 引导框的层级
* 类型number
*/
zIndex: Number,
/**
* 是否显示引导按钮
* 类型boolean
* 默认值true
*/
showTourButtons: makeBooleanProp(true),
/** 查询作用域,限定选择器范围 */
scope: {
type: Object as PropType<any>
},
/**
* 缺失元素处理策略
* 类型string
* 可选值:'skip' | 'stop' | 'hide',分别表示跳过缺失元素、停止引导、隐藏缺失元素的提示
* 默认值:'stop'
*/
missingStrategy: {
type: String as PropType<MissingStrategy>,
default: 'stop'
}
}
export type TourProps = typeof tourProps
export type TourChangeDetail = {
/** 当前步骤的索引 */
current: number
}
export type TourSwitchDetail = {
/** 上一步的索引 */
prevCurrent: number
/** 当前步骤的索引 */
current: number
/** 总步骤数 */
total: number
/** 目标元素是否在屏幕上半部分 */
isElementInTop: boolean
}
export type TourFinishDetail = {
/** 当前步骤的索引 */
current: number
/** 总步骤数 */
total: number
}
export type TourErrorDetail = {
/** 错误信息 */
message: string
/** 目标元素选择器 */
element: string
}
export type TourEmits = {
/**
* 更新 modelValue 事件,用于更新是否显示引导组件
* @param value 是否显示引导组件
*/
'update:modelValue': [value: boolean]
/**
* 更新 current 事件,用于更新当前步骤索引
* @param value 当前步骤索引
*/
'update:current': [value: number]
/**
* 切换事件,用于切换到上一步或下一步
* @param detail 切换事件参数
*/
change: [detail: TourChangeDetail]
/**
* 上一步事件,用于切换到上一步
* @param detail 上一步事件参数
*/
prev: [detail: TourSwitchDetail]
/**
* 下一步事件,用于切换到下一步
* @param detail 下一步事件参数
*/
next: [detail: TourSwitchDetail]
/**
* 完成事件,用于完成引导
* @param detail 完成事件参数
*/
finish: [detail: TourFinishDetail]
/**
* 跳过事件,用于跳过引导
* @param detail 跳过事件参数
*/
skip: [detail: TourFinishDetail]
/**
* 错误事件,用于处理引导过程中出现的错误
* @param detail 错误事件参数
*/
error: [detail: TourErrorDetail]
}

View File

@@ -0,0 +1,451 @@
<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>