From 4aaf257166337fff45d7f54d7159f4aad6e1c324 Mon Sep 17 00:00:00 2001 From: Xiabaiyou <66239072+Xiabaiyou@users.noreply.github.com> Date: Mon, 10 Nov 2025 19:12:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=9C=A8=20=E6=96=B0=E5=A2=9E=20tour?= =?UTF-8?q?=20=E6=BC=AB=E6=B8=B8=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/.vitepress/locales/en-US.ts | 4 + docs/.vitepress/locales/zh-CN.ts | 3 + docs/component/tour.md | 254 +++++++++ src/locale/en-US.json | 1 + src/locale/zh-CN.json | 1 + src/manifest.json | 4 +- src/pages.json | 15 + src/pages/index/Index.vue | 4 + src/subPages/tour/Index.vue | 446 +++++++++++++++ .../components/wd-tour/index.scss | 123 ++++ .../components/wd-tour/types.ts | 153 +++++ .../components/wd-tour/wd-tour.vue | 472 ++++++++++++++++ src/uni_modules/wot-design-uni/global.d.ts | 1 + tests/components/wd-tour.test.ts | 531 ++++++++++++++++++ 14 files changed, 2010 insertions(+), 2 deletions(-) create mode 100644 docs/component/tour.md create mode 100644 src/subPages/tour/Index.vue create mode 100644 src/uni_modules/wot-design-uni/components/wd-tour/index.scss create mode 100644 src/uni_modules/wot-design-uni/components/wd-tour/types.ts create mode 100644 src/uni_modules/wot-design-uni/components/wd-tour/wd-tour.vue create mode 100644 tests/components/wd-tour.test.ts diff --git a/docs/.vitepress/locales/en-US.ts b/docs/.vitepress/locales/en-US.ts index f16e3aed..ebb8e3c0 100644 --- a/docs/.vitepress/locales/en-US.ts +++ b/docs/.vitepress/locales/en-US.ts @@ -241,6 +241,10 @@ export default defineConfig({ { link: '/en-US/component/index-bar', text: 'IndexBar' + }, + { + link: '/en-US/component/tour', + text: 'Tour' } ] }, diff --git a/docs/.vitepress/locales/zh-CN.ts b/docs/.vitepress/locales/zh-CN.ts index 512827ef..8fcf5bfa 100644 --- a/docs/.vitepress/locales/zh-CN.ts +++ b/docs/.vitepress/locales/zh-CN.ts @@ -248,6 +248,9 @@ export default defineConfig({ { link: '/component/index-bar', text: 'IndexBar 索引栏' + },{ + link:'/component/tour', + text: 'Tour 漫游' } ] }, diff --git a/docs/component/tour.md b/docs/component/tour.md new file mode 100644 index 00000000..0ff96404 --- /dev/null +++ b/docs/component/tour.md @@ -0,0 +1,254 @@ +# Guide 漫游组件 + +用于引导用户逐步了解应用功能的组件,可以高亮显示页面中的特定元素并提供说明。 + +## 基本使用 + +通过 `steps` 属性设置引导步骤,通过 `v-model` 控制显示隐藏。 + +```html + + + +``` + +### 自定义引导内容 + +通过 `content` 插槽可以自定义引导内容。 + +```html + + + +``` + +### 自定义高亮区域 + +通过 `highlight` 插槽可以自定义高亮区域样式。 + +```html + + + +``` + +### 自定义按钮 + +通过 `prev`、`next`、`skip`、`finish` 插槽可以自定义按钮。 + +```html + + + + +``` + +### 点击蒙版继续 + +通过 `click-mask-next` 属性可以设置点击蒙版是否可以下一步。 + +```html + +``` + +### 自定义蒙版样式 + +通过 `mask-color`、`offset`、`border-radius`、`padding` 属性可以自定义蒙版样式。 + +```html + +``` + +### 关闭蒙版 + +通过 `mask` 属性可以控制是否显示蒙版。 + +```html + +``` + +### 控制当前步骤 + +通过 `v-model:current` 可以控制当前步骤。 + +```html + + 跳转到第三步 + + +``` + +## 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` 或修改自定义高亮区域的样式,确保按钮可点击。 \ No newline at end of file diff --git a/src/locale/en-US.json b/src/locale/en-US.json index dafa0e62..d0c7a05d 100644 --- a/src/locale/en-US.json +++ b/src/locale/en-US.json @@ -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", diff --git a/src/locale/zh-CN.json b/src/locale/zh-CN.json index d0f37331..f8bb1541 100644 --- a/src/locale/zh-CN.json +++ b/src/locale/zh-CN.json @@ -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": "图标", diff --git a/src/manifest.json b/src/manifest.json index fb33615e..d779f695 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -46,8 +46,8 @@ /* SDK配置 */ "sdkConfigs" : {} }, - "compatible": { - "ignoreVersion": true + "compatible" : { + "ignoreVersion" : true } }, /* 快应用特有相关 */ diff --git a/src/pages.json b/src/pages.json index 1a0ed7ef..dcaf6c7d 100644 --- a/src/pages.json +++ b/src/pages.json @@ -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 + } } ] } diff --git a/src/pages/index/Index.vue b/src/pages/index/Index.vue index ba5149d2..edd68f16 100644 --- a/src/pages/index/Index.vue +++ b/src/pages/index/Index.vue @@ -143,6 +143,10 @@ const list = computed(() => [ { id: 'indexBar', name: t('indexbar-suo-yin-lan') + }, + { + id: 'tour', + name: t('tour-title') } ] }, diff --git a/src/subPages/tour/Index.vue b/src/subPages/tour/Index.vue new file mode 100644 index 00000000..b62d1dc0 --- /dev/null +++ b/src/subPages/tour/Index.vue @@ -0,0 +1,446 @@ + + + + + diff --git a/src/uni_modules/wot-design-uni/components/wd-tour/index.scss b/src/uni_modules/wot-design-uni/components/wd-tour/index.scss new file mode 100644 index 00000000..ff93004f --- /dev/null +++ b/src/uni_modules/wot-design-uni/components/wd-tour/index.scss @@ -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; + } + } +} \ No newline at end of file diff --git a/src/uni_modules/wot-design-uni/components/wd-tour/types.ts b/src/uni_modules/wot-design-uni/components/wd-tour/types.ts new file mode 100644 index 00000000..a90009a0 --- /dev/null +++ b/src/uni_modules/wot-design-uni/components/wd-tour/types.ts @@ -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(), + /** + * 引导框的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>, + default: () => ({}) + }, + + /** + * 引导框的层级 + * 类型:number + * 默认值:999998 + */ + zIndex: makeNumberProp(999998), + + /** + * 是否显示引导按钮 + * 类型:boolean + * 默认值:true + */ + showTourButtons: makeBooleanProp(true) +} + +export type TourProps = typeof tourProps diff --git a/src/uni_modules/wot-design-uni/components/wd-tour/wd-tour.vue b/src/uni_modules/wot-design-uni/components/wd-tour/wd-tour.vue new file mode 100644 index 00000000..b4e845c6 --- /dev/null +++ b/src/uni_modules/wot-design-uni/components/wd-tour/wd-tour.vue @@ -0,0 +1,472 @@ + + + + + + + diff --git a/src/uni_modules/wot-design-uni/global.d.ts b/src/uni_modules/wot-design-uni/global.d.ts index 94014a34..5ec7544e 100644 --- a/src/uni_modules/wot-design-uni/global.d.ts +++ b/src/uni_modules/wot-design-uni/global.d.ts @@ -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'] } } diff --git a/tests/components/wd-tour.test.ts b/tests/components/wd-tour.test.ts new file mode 100644 index 00000000..028beca8 --- /dev/null +++ b/tests/components/wd-tour.test.ts @@ -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: '
自定义高亮区域
' + } + }) + + 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: '
自定义内容
' + } + }) + + 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: '', + next: '', + skip: '', + finish: '' + } + }) + + 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() + }) +})