feat: 新增 tour 漫游组件

This commit is contained in:
Xiabaiyou 2025-11-10 19:12:20 +08:00 committed by GitHub
parent e185a8b291
commit 4aaf257166
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 2010 additions and 2 deletions

View File

@ -241,6 +241,10 @@ export default defineConfig({
{
link: '/en-US/component/index-bar',
text: 'IndexBar'
},
{
link: '/en-US/component/tour',
text: 'Tour'
}
]
},

View File

@ -248,6 +248,9 @@ export default defineConfig({
{
link: '/component/index-bar',
text: 'IndexBar 索引栏'
},{
link:'/component/tour',
text: 'Tour 漫游'
}
]
},

254
docs/component/tour.md Normal file
View File

@ -0,0 +1,254 @@
# Guide 漫游组件
用于引导用户逐步了解应用功能的组件,可以高亮显示页面中的特定元素并提供说明。
## 基本使用
通过 `steps` 属性设置引导步骤,通过 `v-model` 控制显示隐藏。
```html
<template>
<view>
<view class="tour-item" id="step1">
<text class="tour-title">第一步</text>
<text class="tour-content">这是引导的第一步,介绍基本功能</text>
</view>
<view class="tour-item" id="step2">
<text class="tour-title">第二步</text>
<text class="tour-content">这是引导的第二步,展示更多功能</text>
</view>
<wd-tour
:model-value="showTour"
:steps="steps"
v-model:current="current"
@finish="onFinish"
/>
</view>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
const showTour = ref(true)
const current = ref(0)
const steps = [
{
element: '#step1',
content: '这是第一步的说明'
},
{
element: '#step2',
content: '这是第二步的说明'
}
]
function onFinish() {
console.log('引导完成')
}
</script>
```
### 自定义引导内容
通过 `content` 插槽可以自定义引导内容。
```html
<wd-tour :model-value="showTour" :steps="steps">
<template #content>
<view class="custom-content">
<wd-icon name="help-circle-filled" size="22px"></wd-icon>
<text class="custom-text">自定义引导内容区域</text>
</view>
</template>
</wd-tour>
```
### 自定义高亮区域
通过 `highlight` 插槽可以自定义高亮区域样式。
```html
<wd-tour :model-value="showTour" :steps="steps">
<template #highlight="{ elementInfo }">
<view class="custom-highlight" :style="getCustomHighlightStyle(elementInfo)"></view>
</template>
</wd-tour>
```
### 自定义按钮
通过 `prev``next``skip``finish` 插槽可以自定义按钮。
```html
<wd-tour :model-value="showTour" :steps="steps" next-text="继续" finish-text="知道了">
<template #next>
<view class="custom-button custom-next">下一步</view>
</template>
<template #finish>
<view class="custom-button custom-finish">完成</view>
</template>
</wd-tour>
```
### 点击蒙版继续
通过 `click-mask-next` 属性可以设置点击蒙版是否可以下一步。
```html
<wd-tour
:model-value="showTour"
:steps="steps"
:click-mask-next="true"
/>
```
### 自定义蒙版样式
通过 `mask-color``offset``border-radius``padding` 属性可以自定义蒙版样式。
```html
<wd-tour
:model-value="showTour"
:steps="steps"
mask-color="rgba(255, 0, 0, 0.6)"
:offset="40"
:border-radius="15"
:padding="20"
/>
```
### 关闭蒙版
通过 `mask` 属性可以控制是否显示蒙版。
```html
<wd-tour
:model-value="showTour"
:steps="steps"
:mask="false"
/>
```
### 控制当前步骤
通过 `v-model:current` 可以控制当前步骤。
```html
<view>
<wd-button @click="current = 2">跳转到第三步</wd-button>
<wd-tour
:model-value="showTour"
:steps="steps"
v-model:current="current"
/>
</view>
```
## Attributes
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|------|------|------|--------|--------|
| v-model | 是否显示引导组件 | boolean | - | false |
| steps | 引导步骤列表 | array | - | [] |
| current | 当前步骤索引,支持 v-model:current 双向绑定 | number | - | 0 |
| mask | 蒙版是否显示 | boolean | - | true |
| mask-color | 蒙版颜色(支持 rgba 格式) | string | - | rgba(0, 0, 0, 0.5) |
| offset | 引导提示框与高亮框的间距 | number | - | 20 |
| duration | 动画持续时间(毫秒) | number | - | 300 |
| border-radius | 高亮区域的圆角大小 | number | - | 8 |
| padding | 高亮区域的内边距 | number | - | 10 |
| prev-text | 上一步按钮文字 | string | - | 上一步 |
| next-text | 下一步按钮文字 | string | - | 下一步 |
| skip-text | 跳过按钮文字 | string | - | 跳过 |
| finish-text | 完成按钮文字 | string | - | 完成 |
| bottom-safety-offset | 底部安全偏移量,用于滚动计算时确保元素周围有足够的空间 | number | - | 100 |
| top-safety-offset | 顶部安全偏移量,用于滚动计算时确保元素周围有足够的空间 | number | - | 0 |
| custom-nav | 是否自定义顶部导航栏 | boolean | - | false |
| click-mask-next | 点击蒙版是否可以下一步 | boolean | - | false |
| z-index | 引导组件的层级 | number | - | 999998 |
| show-tour-buttons | 是否显示引导按钮 | boolean | - | true |
## Steps 数据结构
| 属性 | 说明 | 类型 |
|------|------|------|
| element | 需要高亮的元素选择器 | string |
| content | 引导文字内容(支持富文本) | string |
## Events
| 事件名 | 说明 | 参数 |
|--------|------|------|
| change | 步骤改变时触发 | `{currentIndex: number }` |
| prev | 点击上一步按钮时触发 | `{ oldCurrent: number, current: number, total: number, isUp: number }` |
| next | 点击下一步按钮时触发 | `{ oldCurrent: number, current: number, total: number, isUp: number }` |
| finish | 点击完成按钮时触发 | `{ current: number, total: number }` |
| skip | 点击跳过按钮时触发 | `{ current: number, total: number }` |
| error | 查找引导元素出错时触发 | `{ message: string, element: string }` |
## Slots
| 插槽名 | 说明 | 参数 |
|--------|------|------|
| highlight | 自定义高亮区域 | elementInfo: 元素位置信息 |
| content | 自定义引导内容 | - |
| prev | 自定义上一步按钮 | - |
| next | 自定义下一步按钮 | - |
| skip | 自定义跳过按钮 | - |
| finish | 自定义完成按钮 | - |
## Methods
通过 ref 可以获取到组件实例,调用组件提供的方法:
| 方法名 | 说明 | 参数 |
|--------|------|------|
| handlePrev | 切换到上一步 | - |
| handleNext | 切换到下一步 | - |
| handleSkip | 跳过引导 | - |
| handleFinish | 完成引导 | - |
## 注意事项
1. 确保要高亮的元素在页面中存在且可选择
2. 在自定义导航栏的情况下,需要设置 `custom-nav` 和适当的 `top-safety-offset`
3. 引导组件会自动处理滚动,确保高亮元素在可视区域内
4. 可以通过 `mask` 属性控制是否显示遮罩层
5. 使用自定义高亮区域时,注意避免遮挡引导操作按钮
6. 在不同平台H5、微信小程序等插槽的使用方式可能略有差异
7. 建议在使用时添加 `v-if``v-show` 条件渲染,以确保组件正确初始化
## 主题定制
组件支持通过 CSS 变量定制主题,可以修改以下变量:
```scss
// 蒙版颜色
--wd-tour-mask-color: rgba(0, 0, 0, 0.5);
// 引导框背景色
--wd-tour-popover-bg-color: #ffffff;
// 按钮背景色
--wd-tour-button-primary-bg-color: #007aff;
// 按钮文字颜色
--wd-tour-button-color: #ffffff;
```
## 常见问题
### 为什么在微信小程序中自定义插槽不显示?
在微信小程序中使用条件渲染与插槽结合时可能会出现问题,建议使用 `v-show` 替代 `v-if` 或将条件判断移到插槽内部。
### 为什么高亮区域会闪烁?
这通常是因为组件初始化时元素信息尚未获取完成导致的。组件已优化初始状态,避免闪烁问题。
### 如何解决点击问题?
如果自定义高亮区域遮挡了操作按钮,可以调整 `z-index` 或修改自定义高亮区域的样式,确保按钮可点击。

View File

@ -985,6 +985,7 @@
"ri-zhou-yue-qie-huan": "Day Week Month Switching",
"ring-lei-xing-loading": "Ring type loading",
"rootportal-title": "Root Portal",
"tour-title": "Tour",
"ru-ding-dan-chu-yu-zan-ting-zhuang-tai-jin-ru-wo-de-ding-dan-ye-mian-zhao-dao-yao-qu-xiao-de-ding-dan-dian-ji-qu-xiao-ding-dan-an-niu-xuan-ze-ding-dan-qu-xiao-yuan-yin-hou-dian-ji-xia-yi-bu-ti-jiao-shen-qing-ji-ke": "If the order is in a suspended state, go to the \"My Orders\" page, find the order you want to cancel, and click the \"Cancel Order\" button; After selecting the reason for canceling the order, click \"Next\" to submit the application.",
"ru-ding-dan-chu-yu-zan-ting-zhuang-tai-jin-ru-wo-de-ding-dan-ye-mian-zhao-dao-yao-qu-xiao-de-ding-dan-dian-ji-qu-xiao-ding-dan-an-niu-xuan-ze-ding-dan-qu-xiao-yuan-yin-hou-dian-ji-xia-yi-bu-ti-jiao-shen-qing-ji-ke-0": "If the order is in a suspended state, go to the \"My Orders\" page, find the order you want to cancel, and click the \"Cancel Order\" button; After selecting the reason for canceling the order, click \"Next\" to submit the application.",
"ruan-jian-gong-cheng": "software engineering",

View File

@ -1175,6 +1175,7 @@
"top-right": "top right",
"tou-bi": "投币",
"tou-xiang-gu-jia-ping": "头像骨架屏",
"tour-title": "Tour 漫游",
"transition-dong-hua": "Transition 动画",
"transition-title": "Transition 动画",
"tu-biao": "图标",

View File

@ -1326,6 +1326,21 @@
"navigationBarTitleText": "%rootportal-title%"
// #endif
}
},
{
"path": "tour/Index",
"name": "tour",
"style": {
"mp-alipay": {
"allowsBounceVertical": "NO"
},
// #ifdef MP
"navigationBarTitleText": "Tour 页面引导",
// #endif
// #ifndef MP
"navigationBarTitleText": "%tour-title%"
// #endif
}
}
]
}

View File

@ -143,6 +143,10 @@ const list = computed(() => [
{
id: 'indexBar',
name: t('indexbar-suo-yin-lan')
},
{
id: 'tour',
name: t('tour-title')
}
]
},

446
src/subPages/tour/Index.vue Normal file
View File

@ -0,0 +1,446 @@
<template>
<page-wraper>
<view class="tour-container">
<view class="tour-step" id="step1">
<view class="tour-item">
<text class="tour-title">第一步</text>
<text class="tour-content">这是引导的第一步介绍基本功能</text>
</view>
</view>
<view class="tour-step" id="step2">
<view class="tour-item">
<text class="tour-title">第二步</text>
<text class="tour-content">这是引导的第二步展示更多功能</text>
</view>
</view>
<view class="tour-step" id="step3">
<view class="tour-item">
<text class="tour-title">第三步</text>
<text class="tour-content">这是引导的第三步深入功能介绍</text>
</view>
</view>
<view class="tour-step" id="step4">
<view class="tour-item">
<text class="tour-title">第四步</text>
<text class="tour-content">这是引导的最后一步总结功能</text>
</view>
</view>
</view>
<!-- 基本用法 -->
<demo-block title="基本用法">
<view class="button-group">
<wd-button type="primary" @click="startBasicTour">开始引导</wd-button>
</view>
</demo-block>
<!-- 点击蒙版继续 -->
<demo-block title="点击蒙版继续">
<view class="button-group">
<wd-button type="primary" @click="startMaskNextTour">点击蒙版继续</wd-button>
</view>
</demo-block>
<!-- 自定义蒙版 -->
<demo-block title="自定义蒙版">
<view class="button-group">
<wd-button type="primary" @click="startCustomMaskTour">自定义蒙版</wd-button>
</view>
</demo-block>
<!-- 关闭蒙版 -->
<demo-block title="关闭蒙版">
<view class="button-group">
<wd-button type="primary" @click="startNoMaskTour">关闭蒙版</wd-button>
</view>
</demo-block>
<!-- 自定义高亮区域 -->
<demo-block title="自定义高亮区域">
<view class="button-group">
<wd-button type="primary" @click="startCustomHighlightTour">自定义高亮</wd-button>
</view>
</demo-block>
<!-- 自定义内容和按钮 -->
<demo-block title="自定义内容和按钮">
<view class="button-group">
<wd-button type="primary" @click="startCustomContentTour">自定义内容</wd-button>
</view>
</demo-block>
<!-- 控制当前步骤 -->
<demo-block title="控制当前步骤">
<view class="button-group">
<wd-button-group>
<wd-button type="primary" @click="startControlTour">跳转到第三步开始引导</wd-button>
</wd-button-group>
</view>
</demo-block>
<!-- 基本用法组件 -->
<wd-tour
v-model="showBasicTour"
:steps="basicSteps"
v-model:current="current"
:padding="10"
@finish="handleFinish"
@skip="handleSkip"
@change="handleChange"
></wd-tour>
<!-- 点击蒙版继续组件 -->
<wd-tour
v-model="showClickMaskTour"
:steps="basicSteps"
:click-mask-next="true"
@finish="handleFinish"
@skip="handleSkip"
@change="handleChange"
></wd-tour>
<!-- 自定义蒙版组件 -->
<wd-tour
v-model="showCustomMaskTour"
:steps="customMaskSteps"
:mask="true"
mask-color="#7eb2f87d"
:offset="40"
:border-radius="15"
:padding="10"
:next-text="'下一步'"
:prev-text="'上一步'"
:skip-text="'跳过'"
:finish-text="'完成'"
@finish="handleFinish"
@skip="handleSkip"
@change="handleChange"
></wd-tour>
<!-- 关闭蒙版组件 -->
<wd-tour v-model="showNoMaskTour" :steps="noMaskSteps" :mask="false" @finish="handleFinish" @skip="handleSkip" @change="handleChange"></wd-tour>
<!-- 自定义高亮区域组件 -->
<wd-tour
v-model="showCustomHighlightTour"
:steps="customHighlightSteps"
:padding="10"
@finish="handleFinish"
@skip="handleSkip"
@change="handleChange"
>
<template #highlight="{ elementInfo }">
<view class="custom-highlight" :style="{ ...elementInfo, ...customHighlightStyle }"></view>
</template>
</wd-tour>
<!-- 自定义内容和按钮组件 -->
<wd-tour
v-model="showCustomContentTour"
:steps="customContentSteps"
:next-text="nextText"
:prev-text="prevText"
:skip-text="skipText"
:finish-text="finishText"
@finish="handleFinish"
@skip="handleSkip"
@change="handleChange"
>
<template #content>
<view class="custom-content">
<wd-icon name="help-circle-filled" size="22px"></wd-icon>
<text class="custom-text">自定义引导内容区域</text>
</view>
</template>
<template #next>
<view class="custom-button custom-next">下一步</view>
</template>
<template #finish>
<view class="custom-button custom-finish">完成</view>
</template>
</wd-tour>
<!-- 控制当前步骤组件 -->
<wd-tour
v-model="showControlTour"
:steps="basicSteps"
v-model:current="controlCurrent"
:padding="10"
@finish="handleFinish"
@skip="handleSkip"
@change="handleChange"
></wd-tour>
</page-wraper>
</template>
<script lang="ts" setup>
import { ref, watch, nextTick } from 'vue'
const showBasicTour = ref(false)
const showClickMaskTour = ref(false)
const showCustomMaskTour = ref(false)
const showNoMaskTour = ref(false)
const showCustomHighlightTour = ref(false)
const showCustomContentTour = ref(false)
const showControlTour = ref(false)
const nextText = ref('继续')
const prevText = ref('返回')
const skipText = ref('跳过')
const finishText = ref('知道了')
const current = ref(0)
const controlCurrent = ref(0)
//
const basicSteps = [
{
element: '#step1',
content: '欢迎使用引导组件,这是第一步的说明'
},
{
element: '#step2',
content: '这是第二步,展示了另一个功能点'
},
{
element: '#step3',
content: '这里可以是<H1>富文本</H1>'
},
{
element: '#step4',
content: '这是最后一步,完成引导流程'
}
]
const customMaskSteps = [
{
element: '#step1',
content: '这是<strong>自定义蒙版</strong>示例,使用了<strong>红色半透明</strong>蒙版'
},
{
element: '#step2',
content: '蒙版颜色设置为<code>rgba(255, 0, 0, 0.6)</code>'
},
{
element: '#step3',
content: '同时调整了<em>高亮区域的圆角</em>、<u>内边距</u>和<code>偏移量</code>'
},
{
element: '#step4',
content: '完成了自定义蒙版样式的展示'
}
]
const noMaskSteps = [
{
element: '#step1',
content: '这是<strong>无蒙版</strong>引导模式'
},
{
element: '#step2',
content: '只高亮目标元素,<em>不显示</em>半透明遮罩'
},
{
element: '#step3',
content: '适用于需要保持页面可见性的场景'
},
{
element: '#step4',
content: '引导完成,<u>不干扰</u>用户查看页面其他内容'
}
]
const customHighlightSteps = [
{
element: '#step1',
content: '这是<strong>自定义高亮区域</strong>示例'
},
{
element: '#step2',
content: '使用了<em>红色虚线边框</em>和<code>半透明背景</code>'
},
{
element: '#step3',
content: '通过插槽实现完全自定义的高亮样式'
},
{
element: '#step4',
content: '完成了自定义高亮区域的展示'
}
]
const customContentSteps = [
{
element: '#step1',
content: '这是<H1>自定义样式</H1>的引导,可以修改<em>蒙版颜色</em>、<u>圆角大小</u>等属性'
},
{
element: '#step2',
content: '可以自定义按钮文字,如"继续"、"返回"等'
},
{
element: '#step3',
content: '通过属性配置实现个性化引导体验'
},
{
element: '#step4',
content: '这是最后一步,完成引导流程'
}
]
//
const customHighlightStyle = {
border: '2px dashed #ff0000',
borderRadius: '8px',
background: 'rgba(255, 0, 0, 0.1)',
boxSizing: 'border-box'
}
//
async function scrollToTop() {
await uni.pageScrollTo({
scrollTop: 0,
duration: 0
})
}
//
async function startBasicTour() {
await scrollToTop()
nextTick(() => {
showBasicTour.value = true
})
}
function startMaskNextTour() {
scrollToTop()
showClickMaskTour.value = true
}
async function startCustomMaskTour() {
scrollToTop()
showCustomMaskTour.value = true
}
function startNoMaskTour() {
scrollToTop()
showNoMaskTour.value = true
}
function startCustomHighlightTour() {
scrollToTop()
showCustomHighlightTour.value = true
}
function startCustomContentTour() {
scrollToTop()
showCustomContentTour.value = true
}
function startControlTour() {
scrollToTop()
controlCurrent.value = 2
showControlTour.value = true
}
//
function handleFinish() {
console.log('引导完成')
showBasicTour.value = false
showClickMaskTour.value = false
showCustomMaskTour.value = false
showNoMaskTour.value = false
showCustomHighlightTour.value = false
showCustomContentTour.value = false
showControlTour.value = false
}
function handleSkip() {
console.log('引导跳过')
showBasicTour.value = false
showClickMaskTour.value = false
showCustomMaskTour.value = false
showNoMaskTour.value = false
showCustomHighlightTour.value = false
showCustomContentTour.value = false
showControlTour.value = false
}
function handleChange(currentIndex: number) {
console.log('当前步骤:', currentIndex)
}
</script>
<style lang="scss" scoped>
.tour-container {
padding: 0;
.tour-step {
width: fit-content;
margin: 20px auto;
.tour-item {
padding: 20px;
border: 1px solid #e5e5e5;
border-radius: 8px;
background-color: #f8f8f8;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
min-width: 250px;
.tour-title {
font-size: 18px;
font-weight: bold;
color: #333;
margin-bottom: 10px;
display: block;
}
.tour-content {
font-size: 14px;
color: #666;
line-height: 1.5;
display: block;
}
}
}
}
.button-group {
padding: 10px 0;
text-align: center;
}
.custom-content {
display: flex;
flex-direction: column;
align-items: center;
padding: 10px;
.custom-text {
font-size: 14px;
color: #333;
}
}
.custom-button {
padding: 6px 12px;
border-radius: 4px;
font-size: 12px;
&.custom-next {
background-color: #34d19d;
color: #fff;
}
&.custom-finish {
background-color: #34d19d;
color: #fff;
}
}
.custom-highlight {
position: fixed;
box-sizing: border-box;
transition: all 0.3s ease-in-out;
}
</style>

View File

@ -0,0 +1,123 @@
@import './../common/abstracts/_mixin.scss';
@import './../common/abstracts/variable.scss';
@include b(tour) {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
z-index: 9999;
@include e(mask) {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
@include e(highlight) {
position: fixed;
background-color: transparent;
box-sizing: content-box;
animation: tour-show 0.3s ease-in-out;
}
@include e(popover) {
z-index: 10000;
background-color: $-color-white;
padding: 10px 12px;
border-radius: 8px;
width: fit-content;
min-width: 200px;
animation: tour-show 0.3s ease;
}
@include e(info) {
font-size: 12px;
background-color: $-color-white;
border-radius: 16rpx;
border: 1px solid $-color-white;
color: $-color-black;
width: fit-content;
text-align: left;
}
@include e(buttons) {
display: flex;
justify-content: flex-end;
align-items: center;
padding-top: 5px;
}
@include e(prev) {
@include e(default){
font-size: 12px;
border-radius: 4px;
padding: 5px 8px;
white-space: nowrap;
}
}
@include e(next) {
@include e(default) {
font-size: 12px;
border-radius: 4px;
padding: 5px 8px;
background-color: $-button-primary-bg-color;
color: $-color-white;
white-space: nowrap;
}
}
@include e(finish) {
@include e(default) {
font-size: 12px;
border-radius: 4px;
padding: 5px 8px;
background-color: $-button-primary-bg-color;
color: $-color-white;
white-space: nowrap;
}
}
@include e(skip) {
@include e(default){
background-color: $-color-white;
color: $-color-black;
font-size: 12px;
padding: 5px 8px;
white-space: nowrap;
}
}
}
@keyframes tour-show {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.wot-theme-dark {
@include b(tour) {
@include e(popover) {
background-color: $-dark-background2;
}
@include e(info) {
background-color: $-dark-background2;
border-color: $-dark-background2;
color: $-dark-color;
}
@include e(skip) {
background-color: $-dark-background2;
color: $-dark-color;
}
}
}

View File

@ -0,0 +1,153 @@
import type { PropType } from 'vue'
import { baseProps, makeBooleanProp, makeNumberProp, makeStringProp, makeArrayProp } from '../common/props'
export interface TourStep {
/**
*
*/
element: string
/**
*
*/
content: string
}
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
* 'rgba(0, 0, 0, 0.5)'
*/
maskColor: makeStringProp('rgba(0, 0, 0, 0.5)'),
/**
*
* number
* 20
*/
offset: makeNumberProp(20),
/**
*
* number
* 300
*/
duration: makeNumberProp(300),
/**
*
* number
* 8
*/
borderRadius: makeNumberProp(8),
/**
*
* number
* 8
*/
padding: makeNumberProp(8),
/**
*
*/
prevText: makeStringProp('上一步'),
/**
*
*/
nextText: makeStringProp('下一步'),
/**
*
*/
skipText: makeStringProp('跳过'),
/**
*
*/
finishText: makeStringProp('完成'),
/**
*
* 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<Record<string, any>>,
default: () => ({})
},
/**
*
* number
* 999998
*/
zIndex: makeNumberProp(999998),
/**
*
* boolean
* true
*/
showTourButtons: makeBooleanProp(true)
}
export type TourProps = typeof tourProps

View File

@ -0,0 +1,472 @@
<template>
<view class="wd-tour" v-if="modelValue" :style="{ zIndex: zIndex }" @touchmove.stop.prevent="noop">
<view class="wd-tour__mask" @click.stop="handleMask">
<slot name="highlight" :elementInfo="highlightElementInfo">
<view class="wd-tour__highlight" :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 } from 'vue'
import { tourProps } from './types'
// #ifdef H5
import useLockScroll from '../composables/useLockScroll'
// #endif
interface ElementRect {
top: number
left: number
width: number
height: number
bottom?: number
right?: number
}
const props = defineProps(tourProps)
const emit = defineEmits(['update:modelValue', 'update:current', 'change', 'prev', 'next', 'finish', 'skip', 'error'])
// #ifdef H5
const { lock, unlock } = useLockScroll(() => props.modelValue)
// #endif
//
const currentIndex = ref(0)
const elementInfo = ref({
top: 0,
left: 0,
width: 0,
height: 0
})
const windowHeight = ref(0)
const windowTop = ref(0)
const isUp = ref(1) //
const oldscrollTop = ref(0) //
const statusBarHeight = ref(0)
const menuButtonInfo = ref(null as UniNamespace.GetMenuButtonBoundingClientRectRes | null)
const topOffset = ref(0)
//
const currentStep = computed(() => {
return props.steps[currentIndex.value] || {}
})
//
function getDefaultStyle() {
return {
top: '0px',
left: '0px',
width: '100vw',
height: '0',
transition: props.duration + 'ms all',
borderRadius: '0px',
padding: '0px'
}
}
//
function calculateHighlightStyle(padding: number, boxShadow: string) {
return {
transition: props.duration + 'ms all,boxShadow 0s,height 0s,width 0s',
borderRadius: props.borderRadius + 'px',
padding: padding + 'px',
boxShadow: boxShadow
}
}
const highlightStyle = computed(() => {
//
if (!elementInfo.value.width && !elementInfo.value.height) {
return getDefaultStyle()
}
const padding = props.padding
//
const boxShadow = props.mask ? `0 0 0 100vh ${props.maskColor}` : 'none'
const baseStyle = calculateHighlightStyle(padding, boxShadow)
return {
...baseStyle,
top: elementInfo.value.top - padding + 'px',
left: elementInfo.value.left - padding + 'px',
height: elementInfo.value.height + 'px',
width: elementInfo.value.width + 'px'
}
})
const popoverStyle = computed(() => {
const style: {
transition: string
position: string
left: string
transform: string
maxWidth: string
textAlign: string
zIndex: number
top?: string
bottom?: string
} = {
transition: props.duration + 'ms all',
position: 'fixed',
left: '50%',
transform: 'translateX(-50%)',
maxWidth: '686rpx',
textAlign: 'center',
zIndex: props.zIndex + 1
}
const padding = props.padding
if (isUp.value === 1) {
//
style.top = elementInfo.value.top + elementInfo.value.height + padding + Number(props.offset) + 'px'
} else {
//
style.bottom = windowHeight.value + windowTop.value - elementInfo.value.top + padding + Number(props.offset) + 'px'
}
return style
})
const highlightElementInfo = computed(() => {
const padding = props.padding
const boxShadow = props.mask ? `0 0 0 100vh ${props.maskColor}` : 'none'
//
if (!elementInfo.value.width && !elementInfo.value.height) {
return getDefaultStyle()
}
const baseStyle = calculateHighlightStyle(padding, boxShadow)
return {
...baseStyle,
top: elementInfo.value.top - padding + 'px',
left: elementInfo.value.left - padding + 'px',
width: elementInfo.value.width + padding * 2 + 'px', // padding
height: elementInfo.value.height + padding * 2 + 'px' // padding
}
})
function noop() {}
//
function updateElementInfo() {
//
updateSystemInfo()
const element = currentStep.value.element
if (!element) return
try {
const query = uni.createSelectorQuery()
query
.select(element)
.boundingClientRect((res: any) => {
if (!res) {
console.error('无法找到元素:', element)
emit('error', {
message: '无法找到指定的引导元素',
element: element
})
return
}
//
initializeElementInfo(res)
//
const effectiveBoundaries = getEffectiveBoundaries()
//
const scrollNeeds = checkScrollNeeds(res, effectiveBoundaries)
//
handleScrolling(res, scrollNeeds, effectiveBoundaries)
//
calculateTipPosition(res, effectiveBoundaries)
})
.exec()
} catch (error) {
console.error('updateElementInfo error:', error)
}
}
//
function updateSystemInfo() {
const sysInfo = uni.getSystemInfoSync()
windowHeight.value = sysInfo.windowHeight
windowTop.value = sysInfo.windowTop || 0
statusBarHeight.value = sysInfo.statusBarHeight || 0
}
//
function initializeElementInfo(res: ElementRect) {
elementInfo.value = res
//
elementInfo.value.top = res.top + windowTop.value
elementInfo.value.bottom = res.bottom + windowTop.value
}
//
function getEffectiveBoundaries() {
// +
let effectiveWindowTop = windowTop.value + Number(topOffset.value)
//
let effectiveWindowBottom = windowHeight.value
return {
top: effectiveWindowTop,
bottom: effectiveWindowBottom
}
}
//
function checkScrollNeeds(res: ElementRect, boundaries: { top: number; bottom: number }) {
//
const needScrollUp = 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: ElementRect, scrollNeeds, boundaries: { top: number; bottom: number }) {
if (scrollNeeds.up) {
//
scrollUp(res, boundaries)
} else if (scrollNeeds.down) {
//
scrollDown(res)
}
}
//
function scrollUp(res: ElementRect, boundaries: { top: number; bottom: number }) {
//
let scrollDistance = oldscrollTop.value + 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: () => {
//
oldscrollTop.value = scrollDistance
}
})
}
//
function scrollDown(res: ElementRect) {
//
let scrollDistance = (res.bottom ?? 0) - windowHeight.value + props.padding + Number(props.bottomSafetyOffset)
//
elementInfo.value.top = windowHeight.value - res.height - props.padding - Number(props.bottomSafetyOffset) //
elementInfo.value.bottom = windowHeight.value - props.padding - Number(props.bottomSafetyOffset)
uni.pageScrollTo({
scrollTop: scrollDistance + oldscrollTop.value,
duration: Number(props.duration),
success: () => {
//
oldscrollTop.value = scrollDistance + oldscrollTop.value
}
})
}
//
function calculateTipPosition(res: ElementRect, boundaries: { top: number; bottom: number }) {
//
let totalNavHeight = statusBarHeight.value
// #ifdef MP
//
// if (props.customNav) {
// // const menuBottom = this.menuButtonInfo.top + this.menuButtonInfo.height;
// const menuBottom = menuButtonInfo.value.top;
// totalNavHeight = Math.max(totalNavHeight, menuBottom);
// }
// #endif
// totalNavHeight = Math.max(totalNavHeight, topOffset.value);
//
const screenCenter = (windowHeight.value + totalNavHeight) / 2 + windowTop.value
//
const elementCenter = res.top + res.height / 2 + windowTop.value
//
if (elementCenter < screenCenter) {
isUp.value = 1 //
} else {
isUp.value = 0 //
}
}
function handlePrev() {
if (currentIndex.value > 0) {
const oldIndex = currentIndex.value
currentIndex.value--
emit('prev', {
oldCurrent: oldIndex,
current: currentIndex.value,
total: props.steps.length,
isUp: isUp.value
})
emit('change', currentIndex.value)
}
}
function handleNext() {
if (currentIndex.value < props.steps.length - 1) {
const oldIndex = currentIndex.value
currentIndex.value++
emit('next', {
oldCurrent: oldIndex,
current: currentIndex.value,
total: props.steps.length,
isUp: isUp.value
})
emit('change', currentIndex.value)
} else {
handleFinish()
}
}
function handleFinish() {
emit('finish', {
current: currentIndex.value,
total: props.steps.length
})
currentIndex.value = 0
oldscrollTop.value = 0 //
emit('update:modelValue', false)
}
function handleSkip() {
emit('skip', {
current: currentIndex.value,
total: props.steps.length
})
currentIndex.value = 0
oldscrollTop.value = 0 //
emit('update:modelValue', false)
}
function handleMask() {
if (props.clickMaskNext) {
handleNext()
}
}
watch(
() => props.current,
(newVal) => {
currentIndex.value = newVal
}
)
// currentIndex
watch(
() => currentIndex.value,
(newVal) => {
nextTick(() => {
setTimeout(() => {
updateElementInfo()
}, 50)
})
emit('update:current', newVal)
}
)
// modelValue
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
//
oldscrollTop.value = 0
updateSystemInfo()
nextTick(() => {
setTimeout(() => {
updateElementInfo()
emit('update:current', currentIndex.value)
}, 50)
})
}
},
{
immediate: true
}
)
//
// updateSystemInfo()
//
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>

View File

@ -95,6 +95,7 @@ declare module 'vue' {
WdFloatingPanel: typeof import('./components/wd-floating-panel/wd-floating-panel.vue')['default']
WdSignature: typeof import('./components/wd-signature/wd-signature.vue')['default']
WdRootPortal: typeof import('./components/wd-root-portal/wd-root-portal.vue')['default']
WdTour: typeof import('./components/wd-tour/wd-tour.vue')['default']
}
}

View File

@ -0,0 +1,531 @@
import { mount } from '@vue/test-utils'
import WdTour from '@/uni_modules/wot-design-uni/components/wd-tour/wd-tour.vue'
import { describe, test, expect, vi, beforeEach } from 'vitest'
// 模拟 useLockScroll 函数
vi.mock('@/uni_modules/wot-design-uni/components/composables/useLockScroll', () => ({
default: vi.fn(() => ({
lock: vi.fn(),
unlock: vi.fn()
}))
}))
// 模拟uni对象
const mockUni = {
createSelectorQuery: vi.fn(() => ({
select: vi.fn(() => ({
boundingClientRect: vi.fn((callback) => {
// 模拟元素位置信息
setTimeout(() => {
callback({
top: 100,
left: 50,
width: 200,
height: 100
})
}, 10)
return {
exec: vi.fn()
}
})
}))
})),
pageScrollTo: vi.fn(),
getSystemInfoSync: vi.fn(() => ({
windowHeight: 600,
windowTop: 0,
statusBarHeight: 20
})),
getMenuButtonBoundingClientRect: vi.fn()
}
// 模拟全局uni对象
Object.defineProperty(global, 'uni', {
value: mockUni
})
describe('WdTour', () => {
const steps = [
{
element: '#step1',
content: '这是第一步'
},
{
element: '#step2',
content: '这是第二步'
}
]
beforeEach(() => {
vi.clearAllMocks()
// 重置模拟函数
mockUni.createSelectorQuery.mockClear()
mockUni.createSelectorQuery.mockImplementation(() => ({
select: vi.fn(() => ({
boundingClientRect: vi.fn((callback) => {
// 模拟元素位置信息
setTimeout(() => {
callback({
top: 100,
left: 50,
width: 200,
height: 100
})
}, 10)
return {
exec: vi.fn()
}
})
}))
}))
})
// 测试基本渲染
test('基本渲染', async () => {
const wrapper = mount(WdTour, {
props: {
modelValue: true,
steps
}
})
// 等待 DOM 更新
await wrapper.vm.$nextTick()
expect(wrapper.classes()).toContain('wd-tour')
expect(wrapper.find('.wd-tour__mask').exists()).toBe(true)
})
// 测试隐藏状态
test('隐藏状态', () => {
const wrapper = mount(WdTour, {
props: {
modelValue: false,
steps
}
})
expect(wrapper.find('.wd-tour').exists()).toBe(false)
})
// 测试步骤内容渲染
test('渲染步骤内容', async () => {
const wrapper = mount(WdTour, {
props: {
modelValue: true,
steps,
current: 0
}
})
// 等待 DOM 更新和元素查询完成
await wrapper.vm.$nextTick()
await new Promise((resolve) => setTimeout(resolve, 100))
expect(wrapper.find('.wd-tour__info').exists()).toBe(true)
// 验证富文本内容是否正确渲染
const richText = wrapper.find('.wd-tour__info rich-text')
expect(richText.exists()).toBe(true)
})
// 测试按钮显示
test('显示导航按钮', async () => {
const wrapper = mount(WdTour, {
props: {
modelValue: true,
steps,
current: 0
}
})
await wrapper.vm.$nextTick()
await new Promise((resolve) => setTimeout(resolve, 100))
// 第一步应该显示"跳过"和"下一步"按钮
expect(wrapper.find('.wd-tour__skip').exists()).toBe(true)
expect(wrapper.find('.wd-tour__next').exists()).toBe(true)
expect(wrapper.find('.wd-tour__prev').exists()).toBe(false)
expect(wrapper.find('.wd-tour__finish').exists()).toBe(false)
})
// 测试最后一步按钮
test('最后一步显示完成按钮', async () => {
const wrapper = mount(WdTour, {
props: {
modelValue: true,
steps,
current: 0
}
})
await wrapper.vm.$nextTick()
await new Promise((resolve) => setTimeout(resolve, 150))
// 更新 current 属性
await wrapper.setProps({ current: 1 })
await wrapper.vm.$nextTick()
await new Promise((resolve) => setTimeout(resolve, 150))
// 最后一步应该显示"上一步"和"完成"按钮
expect(wrapper.find('.wd-tour__prev').exists()).toBe(true)
expect(wrapper.find('.wd-tour__finish').exists()).toBe(true)
expect(wrapper.find('.wd-tour__next').exists()).toBe(false)
expect(wrapper.find('.wd-tour__skip').exists()).toBe(true)
})
// ... existing code ...
// 测试自定义按钮文字
test('自定义按钮文字', async () => {
const wrapper = mount(WdTour, {
props: {
modelValue: true,
steps,
current: 0,
prevText: '上一页',
nextText: '下一页',
skipText: '忽略',
finishText: '结束'
}
})
await wrapper.vm.$nextTick()
await new Promise((resolve) => setTimeout(resolve, 100))
expect(wrapper.find('.wd-tour__skip__default').text()).toBe('忽略')
expect(wrapper.find('.wd-tour__finish__default').exists()).toBe(false) // 不在第一页
})
// 测试进度显示
test('显示进度', async () => {
const wrapper = mount(WdTour, {
props: {
modelValue: true,
steps,
current: 0
}
})
await wrapper.vm.$nextTick()
await new Promise((resolve) => setTimeout(resolve, 100))
expect(wrapper.find('.wd-tour__next__default').text()).toContain('(1/2)')
})
// 测试点击下一步
test('点击下一步按钮', async () => {
const wrapper = mount(WdTour, {
props: {
modelValue: true,
steps,
current: 0
}
})
await wrapper.vm.$nextTick()
await new Promise((resolve) => setTimeout(resolve, 100))
const nextButton = wrapper.find('.wd-tour__next')
await nextButton.trigger('click')
// 验证是否发出 next 事件
expect(wrapper.emitted('next')).toBeTruthy()
expect(wrapper.emitted('change')).toBeTruthy()
})
// 测试点击上一步按钮
test('点击上一步按钮', async () => {
const wrapper = mount(WdTour, {
props: {
modelValue: true,
steps,
current: 0
}
})
await wrapper.vm.$nextTick()
await new Promise((resolve) => setTimeout(resolve, 150))
// 更新 current 属性
await wrapper.setProps({ current: 1 })
await wrapper.vm.$nextTick()
await new Promise((resolve) => setTimeout(resolve, 150))
const prevButton = wrapper.find('.wd-tour__prev')
expect(prevButton.exists()).toBe(true)
await prevButton.trigger('click')
// 验证是否发出 prev 事件
expect(wrapper.emitted('prev')).toBeTruthy()
expect(wrapper.emitted('change')).toBeTruthy()
})
// 测试点击完成按钮
test('点击完成按钮', async () => {
const wrapper = mount(WdTour, {
props: {
modelValue: true,
steps,
current: 0
}
})
await wrapper.vm.$nextTick()
await new Promise((resolve) => setTimeout(resolve, 150))
// 更新 current 属性
await wrapper.setProps({ current: 1 })
await wrapper.vm.$nextTick()
await new Promise((resolve) => setTimeout(resolve, 150))
const finishButton = wrapper.find('.wd-tour__finish')
expect(finishButton.exists()).toBe(true)
await finishButton.trigger('click')
// 验证是否发出 finish 事件和更新 modelValue
expect(wrapper.emitted('finish')).toBeTruthy()
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
})
// 测试点击跳过按钮
test('点击跳过按钮', async () => {
const wrapper = mount(WdTour, {
props: {
modelValue: true,
steps,
current: 0
}
})
await wrapper.vm.$nextTick()
await new Promise((resolve) => setTimeout(resolve, 100))
const skipButton = wrapper.find('.wd-tour__skip')
await skipButton.trigger('click')
// 验证是否发出 skip 事件和更新 modelValue
expect(wrapper.emitted('skip')).toBeTruthy()
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
})
// 测试点击蒙版
test('点击蒙版触发下一步', async () => {
const wrapper = mount(WdTour, {
props: {
modelValue: true,
steps,
current: 0,
clickMaskNext: true
}
})
await wrapper.vm.$nextTick()
await new Promise((resolve) => setTimeout(resolve, 100))
const mask = wrapper.find('.wd-tour__mask')
await mask.trigger('click')
// 验证是否触发下一步
expect(wrapper.emitted('next')).toBeTruthy()
})
// 测试禁用点击蒙版
test('禁用点击蒙版', async () => {
const wrapper = mount(WdTour, {
props: {
modelValue: true,
steps,
current: 0,
clickMaskNext: false
}
})
await wrapper.vm.$nextTick()
await new Promise((resolve) => setTimeout(resolve, 100))
const mask = wrapper.find('.wd-tour__mask')
await mask.trigger('click')
// 验证不触发下一步
expect(wrapper.emitted('next')).toBeFalsy()
})
// 测试蒙版显示
test('显示/隐藏蒙版', async () => {
const wrapper = mount(WdTour, {
props: {
modelValue: true,
steps,
current: 0,
mask: false
}
})
await wrapper.vm.$nextTick()
await new Promise((resolve) => setTimeout(resolve, 100))
// 等待元素信息更新完成
await new Promise((resolve) => setTimeout(resolve, 100))
// 当 mask 为 false 时boxShadow 应该为 none
const highlightStyle = wrapper.find('.wd-tour__highlight').attributes('style')
expect(highlightStyle).toContain('box-shadow: none')
})
// 测试自定义高亮区域插槽
test('使用自定义高亮区域插槽', async () => {
const wrapper = mount(WdTour, {
props: {
modelValue: true,
steps,
current: 0
},
slots: {
highlight: '<div class="custom-highlight">自定义高亮区域</div>'
}
})
await wrapper.vm.$nextTick()
await new Promise((resolve) => setTimeout(resolve, 100))
expect(wrapper.find('.custom-highlight').exists()).toBe(true)
expect(wrapper.find('.wd-tour__highlight').exists()).toBe(false)
})
// 测试自定义内容插槽
test('使用自定义内容插槽', async () => {
const wrapper = mount(WdTour, {
props: {
modelValue: true,
steps,
current: 0
},
slots: {
content: '<div class="custom-content">自定义内容</div>'
}
})
await wrapper.vm.$nextTick()
await new Promise((resolve) => setTimeout(resolve, 100))
expect(wrapper.find('.custom-content').exists()).toBe(true)
expect(wrapper.find('.wd-tour__info').exists()).toBe(false)
})
// 测试自定义按钮插槽
test('使用自定义按钮插槽', async () => {
const wrapper = mount(WdTour, {
props: {
modelValue: true,
steps,
current: 0
},
slots: {
prev: '<button class="custom-prev">上一步</button>',
next: '<button class="custom-next">下一步</button>',
skip: '<button class="custom-skip">跳过</button>',
finish: '<button class="custom-finish">完成</button>'
}
})
await wrapper.vm.$nextTick()
await new Promise((resolve) => setTimeout(resolve, 100))
// 在第一页时prev和finish按钮不应该存在
expect(wrapper.find('.custom-prev').exists()).toBe(false)
expect(wrapper.find('.custom-next').exists()).toBe(true)
expect(wrapper.find('.custom-skip').exists()).toBe(true)
expect(wrapper.find('.custom-finish').exists()).toBe(false)
})
// 测试样式属性
test('应用样式属性', async () => {
const wrapper = mount(WdTour, {
props: {
modelValue: true,
steps,
current: 0,
maskColor: 'rgba(255, 0, 0, 0.5)',
offset: 30,
borderRadius: 10,
padding: 15
}
})
await wrapper.vm.$nextTick()
await new Promise((resolve) => setTimeout(resolve, 100))
// 等待元素信息更新完成
await new Promise((resolve) => setTimeout(resolve, 100))
const highlightStyle = wrapper.find('.wd-tour__highlight').attributes('style')
expect(highlightStyle).toContain('border-radius: 10px')
expect(highlightStyle).toContain('padding: 15px')
})
// 测试 zIndex 属性
test('应用 zIndex', async () => {
const zIndex = 10000
const wrapper = mount(WdTour, {
props: {
modelValue: true,
steps,
current: 0,
zIndex
}
})
await wrapper.vm.$nextTick()
const style = wrapper.find('.wd-tour').attributes('style')
expect(style).toContain(`z-index: ${zIndex}`)
})
// 测试禁用引导按钮
test('禁用引导按钮', async () => {
const wrapper = mount(WdTour, {
props: {
modelValue: true,
steps,
current: 0,
showTourButtons: false
}
})
await wrapper.vm.$nextTick()
await new Promise((resolve) => setTimeout(resolve, 100))
expect(wrapper.find('.wd-tour__buttons').exists()).toBe(false)
})
// 测试错误处理
test('处理元素查找错误', async () => {
// 模拟找不到元素的情况
mockUni.createSelectorQuery.mockImplementation(() => ({
select: vi.fn(() => ({
boundingClientRect: vi.fn((callback) => {
// 模拟找不到元素
setTimeout(() => {
callback(null)
}, 10)
return {
exec: vi.fn()
}
})
}))
}))
const wrapper = mount(WdTour, {
props: {
modelValue: true,
steps,
current: 0
}
})
await wrapper.vm.$nextTick()
await new Promise((resolve) => setTimeout(resolve, 100))
// 验证是否发出 error 事件
expect(wrapper.emitted('error')).toBeTruthy()
})
})