Browse Source

feat: 新增 tour 组件

yangxiaolu3 3 years ago
parent
commit
23a351527c

+ 11 - 0
src/config.json

@@ -1104,6 +1104,17 @@
           "tarodoc": false,
           "type": "component",
           "author": "liukun"
+        },
+        {
+          "version": "3.0.0",
+          "name": "Tour",
+          "cType": "展示组件",
+          "cName": "引导组件",
+          "desc": "漫游式引导组件",
+          "show": true,
+          "tarodoc": false,
+          "type": "component",
+          "author": "yangxiaolu"
         }
       ]
     },

+ 51 - 15
src/packages/__VUE/popover/demo.vue

@@ -4,14 +4,26 @@
 
     <nut-row type="flex">
       <nut-col :span="8">
-        <nut-popover v-model:visible="lightTheme" :list="iconItemList" location="bottom-start" @choose="chooseItem">
+        <nut-popover
+          v-model:visible="lightTheme"
+          :list="iconItemList"
+          location="bottom-end"
+          @choose="chooseItem"
+          overlay="true"
+        >
           <template #reference>
             <nut-button type="primary" shape="square">{{ translate('light') }}</nut-button>
           </template>
         </nut-popover>
       </nut-col>
       <nut-col :span="8">
-        <nut-popover v-model:visible="darkTheme" theme="dark" :list="iconItemList">
+        <nut-popover
+          v-model:visible="darkTheme"
+          theme="dark"
+          location="bottom-start"
+          :list="iconItemList"
+          :offset="[0, 12]"
+        >
           <template #reference>
             <nut-button type="primary" shape="square">{{ translate('dark') }}</nut-button>
           </template>
@@ -30,7 +42,7 @@
         </nut-popover>
       </nut-col>
       <nut-col :span="8">
-        <nut-popover v-model:visible="disableAction" :list="itemListDisabled" location="bottom-end">
+        <nut-popover v-model:visible="disableAction" :list="itemListDisabled" location="right">
           <template #reference>
             <nut-button type="primary" shape="square">{{ translate('disableAction') }}</nut-button>
           </template>
@@ -39,7 +51,7 @@
     </nut-row>
 
     <h2>{{ translate('title2') }}</h2>
-    <nut-popover v-model:visible="Customized" location="top-start" custom-class="customClass">
+    <nut-popover v-model:visible="Customized" location="top" custom-class="customClass">
       <template #reference>
         <nut-button type="primary" shape="square">{{ translate('content') }}</nut-button>
       </template>
@@ -60,14 +72,27 @@
     <nut-picker v-model:visible="showPicker" :columns="columns" title="" @change="change" :swipe-duration="500">
       <template #top>
         <div class="brickBox">
-          <nut-popover v-model:visible="customPositon" :location="curPostion" theme="dark" :list="positionList">
-            <template #reference>
-              <div class="brick"></div>
-            </template>
-          </nut-popover>
+          <div class="brick" id="pickerTarget"></div>
         </div>
       </template>
     </nut-picker>
+
+    <nut-popover
+      v-model:visible="customPositon"
+      targetId="pickerTarget"
+      :location="curPostion"
+      bgColor="#f00"
+      theme="dark"
+      :list="positionList"
+    >
+    </nut-popover>
+
+    <h2>自定义对象</h2>
+    <nut-button type="primary" shape="square" id="popid" @click="clickCustomHandle;">{{
+      translate('light')
+    }}</nut-button>
+    <nut-popover v-model:visible="customTarget" targetId="popid" :list="iconItemList" location="top-start">
+    </nut-popover>
   </div>
 </template>
 <script lang="ts">
@@ -116,7 +141,9 @@ export default createDemo({
       leftLocation: false, //向左弹出
       customPositon: false,
 
-      showPicker: false
+      showPicker: false,
+
+      customTarget: false
     });
     const curPostion = ref('top');
 
@@ -220,12 +247,18 @@ export default createDemo({
       state.showPicker = true;
       setTimeout(() => {
         state.customPositon = true;
-      });
+      }, 0);
     };
 
     const change = ({ selectedValue }) => {
+      console.log('change');
       curPostion.value = selectedValue[0];
-      state.customPositon = true;
+      if (state.showPicker) state.customPositon = true;
+    };
+
+    const clickCustomHandle = () => {
+      console.log('点击', state.customTarget);
+      state.customTarget = !state.customTarget;
     };
     return {
       iconItemList,
@@ -239,7 +272,8 @@ export default createDemo({
       translate,
       columns,
       change,
-      handlePicker
+      handlePicker,
+      clickCustomHandle
     };
   }
 });
@@ -276,8 +310,10 @@ export default createDemo({
   }
 }
 
-.nut-popover-content {
-  width: 120px;
+.demo {
+  .nut-popover-content {
+    width: 120px;
+  }
 }
 
 .customClass {

+ 10 - 38
src/packages/__VUE/popover/index.scss

@@ -1,13 +1,8 @@
 .nut-popover {
-  position: relative;
+  position: absolute;
   display: inline-block;
   word-break: normal;
 
-  > .nut-popover-wrapper {
-    display: inline-block;
-    vertical-align: top;
-  }
-
   &-arrow {
     position: absolute;
     width: 0;
@@ -112,8 +107,8 @@
 
     .nut-popover-menu-item {
       display: block;
-      padding-bottom: 8px;
-      margin: 8px;
+      // padding-bottom: 8px;
+      padding: 8px;
       border-bottom: 1px solid $popover-border-bottom-color;
 
       &:first-child {
@@ -145,9 +140,6 @@
     }
 
     &--top {
-      left: 50%;
-      transform: translateX(-50%);
-
       .nut-popover-arrow--top {
         left: 50%;
         transform: translateX(-50%);
@@ -172,28 +164,10 @@
       }
     }
 
-    // bottom
-    &--bottom {
-      left: 50%;
-      transform: translateX(-50%);
-      // transform-origin:50% 0 ;
-    }
-
     &--bottom-end {
       right: 0;
     }
 
-    &--bottom-start {
-      left: 0;
-      // transform-origin:0 0;
-    }
-
-    // left
-    &--left {
-      top: 50%;
-      transform: translateY(-50%);
-    }
-
     &--left-end {
       bottom: 0;
     }
@@ -202,12 +176,6 @@
       top: 0;
     }
 
-    // right
-    &--right {
-      top: 50%;
-      transform: translateY(-50%);
-    }
-
     &--right-end {
       bottom: 0;
     }
@@ -220,8 +188,8 @@
 
 .nut-popover--dark {
   .nut-popover-content {
-    background: $popover-dark-background-color !important;
-    color: $popover-white-background-color !important;
+    background: $popover-dark-background-color;
+    color: $popover-white-background-color;
 
     &--bottom,
     &--bottom-start,
@@ -259,7 +227,7 @@
 
 .nut-popover-enter-from,
 .nut-popover-leave-active {
-  // transform: scale(0.8);
+  transform: scale(0.8);
   opacity: 0;
 }
 
@@ -280,3 +248,7 @@
   background: transparent;
   z-index: 1999;
 }
+
+.nut-popover-wrapper {
+  display: inline-block;
+}

+ 113 - 49
src/packages/__VUE/popover/index.vue

@@ -1,10 +1,9 @@
 <template>
-  <view :class="['nut-popover', `nut-popover--${theme}`, `${customClass}`]">
-    <view class="nut-popover-wrapper" @click="openPopover" ref="popoverRef"><slot name="reference"></slot></view>
-
+  <div class="nut-popover-wrapper" @click="openPopover" ref="popoverRef"><slot name="reference"></slot></div>
+  <view :class="['nut-popover', `nut-popover--${theme}`, `${customClass}`]" :style="getRootPosition">
     <nut-popup
       :popClass="`nut-popover-content nut-popover-content--${location}`"
-      :style="getStyles"
+      :style="customStyle"
       v-model:visible="showPopup"
       position=""
       transition="nut-popover"
@@ -15,7 +14,7 @@
       :closeOnClickOverlay="closeOnClickOverlay"
     >
       <view ref="popoverContentRef" class="nut-popover-content-group">
-        <view :class="popoverArrow" v-if="showArrow"> </view>
+        <view :class="popoverArrow" v-if="showArrow" :style="popoverArrowStyle"> </view>
         <slot name="content"></slot>
         <view
           v-for="(item, index) in list"
@@ -24,12 +23,7 @@
           @click.stop="chooseItem(item, index)"
         >
           <slot v-if="item.icon">
-            <nut-icon
-              v-bind="$attrs"
-              class="nut-popover-item-img"
-              :classPrefix="iconPrefix"
-              :name="item.icon"
-            ></nut-icon
+            <Icon v-bind="$attrs" class="nut-popover-item-img" :classPrefix="iconPrefix" :name="item.icon"></Icon
           ></slot>
           <view class="nut-popover-menu-item-name">{{ item.name }}</view>
         </view>
@@ -41,9 +35,13 @@
 import { computed, watch, ref, PropType, CSSProperties, reactive } from 'vue';
 import { createComponent } from '@/packages/utils/create';
 import { isArray } from '@/packages/utils/util';
+import { useRect, rect } from '@/packages/utils/useRect';
+import { Icon } from '@nutui/icons-vue';
 const { create } = createComponent('popover');
 export default create({
-  components: {},
+  components: {
+    Icon
+  },
   props: {
     visible: { type: Boolean, default: false },
     list: { type: Array, default: [] },
@@ -59,17 +57,22 @@ export default create({
     overlayStyle: { type: Object as PropType<CSSProperties> },
     closeOnClickOverlay: { type: Boolean, default: true },
     closeOnClickAction: { type: Boolean, default: true },
-    closeOnClickOutside: { type: Boolean, default: true }
+    closeOnClickOutside: { type: Boolean, default: true },
+    targetId: { type: String, default: '' },
+    bgColor: { type: String, default: '' }
   },
   emits: ['update', 'update:visible', 'close', 'choose', 'open'],
   setup(props, { emit }) {
     const popoverRef = ref();
     const popoverContentRef = ref();
     const showPopup = ref(props.visible);
-    const state = reactive({
-      rootWidth: 0,
-      rootHeight: 0
-    });
+
+    let rootRect = ref<rect>();
+
+    let conentRootRect = ref<{
+      height: number;
+      width: number;
+    }>();
 
     const popoverArrow = computed(() => {
       const prefixCls = 'nut-popover-arrow';
@@ -77,36 +80,94 @@ export default create({
       const direction = loca.split('-')[0];
       return `${prefixCls} ${prefixCls}-${direction} ${prefixCls}--${loca}`;
     });
-    const getStyles = computed(() => {
-      let cross = +state.rootHeight;
-      let lengthways = +state.rootWidth;
-      let { offset, location } = props;
+
+    const popoverArrowStyle = computed(() => {
+      const styles: CSSProperties = {};
+      const direction = upperCaseFirst(props.location.split('-')[0]);
+      if (props.bgColor) {
+        styles[`border${direction}Color`] = props.bgColor;
+      }
+
+      return styles;
+    });
+
+    const upperCaseFirst = (str: string) => {
+      var str = str.toLowerCase();
+      str = str.replace(/\b\w+\b/g, (word) => word.substring(0, 1).toUpperCase() + word.substring(1));
+      return str;
+    };
+
+    const getRootPosition = computed(() => {
+      let styles: CSSProperties = {};
+
+      if (!rootRect.value || !conentRootRect.value) return {};
+
+      const conentWidth = conentRootRect.value.width;
+      const conentHeight = conentRootRect.value.height;
+      const { width, height, left, top } = rootRect.value;
+
+      const { location, offset } = props;
+      const direction = location.split('-')[0];
+      const skew = location.split('-')[1];
+      let cross = 0;
       if (isArray(offset) && offset.length == 2) {
         cross += +offset[1];
-        lengthways += +offset[1];
       }
-      const direction = location.split('-')[0];
-      const style: CSSProperties = {};
-      const mapd: any = {
-        top: 'bottom',
-        bottom: 'top',
-        left: 'right',
-        right: 'left'
-      };
-      if (['top', 'bottom'].includes(direction)) {
-        style[mapd[direction]] = `${cross}px`;
-        style.marginLeft = `${offset[0]}px`;
-      } else {
-        style[mapd[direction]] = `${lengthways}px`;
-        style.marginTop = `${offset[0]}px`;
+      if (width) {
+        if (['bottom', 'top'].includes(direction)) {
+          console.log('高度', conentHeight);
+          const h = direction == 'bottom' ? height + cross : -(conentHeight + cross);
+          styles.top = `${top + h}px`;
+
+          if (!skew) {
+            styles.left = `${-(conentWidth - width) / 2 + left}px`;
+          }
+          if (skew == 'start') {
+            styles.left = `${left}px`;
+          }
+          if (skew == 'end') {
+            styles.left = `${rootRect.value.right}px`;
+          }
+        }
+        if (['left', 'right'].includes(direction)) {
+          const contentW = direction == 'left' ? -(conentWidth + cross) : width + cross;
+          styles.left = `${left + contentW}px`;
+          if (!skew) {
+            styles.top = `${top - conentHeight / 2 + height / 2 - 4}px`;
+          }
+          if (skew == 'start') {
+            styles.top = `${top}px`;
+          }
+          if (skew == 'end') {
+            styles.top = `${top + height}px`;
+          }
+        }
+      }
+
+      return styles;
+    });
+
+    const customStyle = computed(() => {
+      const styles: CSSProperties = {};
+      if (props.bgColor) {
+        styles.background = props.bgColor;
       }
-      return style;
+
+      return styles;
     });
     // 获取宽度
     const getContentWidth = () => {
-      const { offsetHeight, offsetWidth } = popoverRef.value;
-      state.rootHeight = offsetHeight;
-      state.rootWidth = offsetWidth;
+      let rect = useRect(popoverRef.value);
+      if (props.targetId) {
+        rect = useRect(document.querySelector(`#${props.targetId}`));
+      }
+      rootRect.value = rect;
+      setTimeout(() => {
+        conentRootRect.value = {
+          height: popoverContentRef.value.clientHeight,
+          width: popoverContentRef.value.clientWidth
+        };
+      }, 0);
     };
     watch(
       () => props.visible,
@@ -141,13 +202,14 @@ export default create({
     const clickAway = (event: Event) => {
       const element = popoverRef.value;
       const elContent = popoverContentRef.value;
-      if (
-        element &&
-        !element.contains(event.target) &&
-        elContent &&
-        !elContent.contains(event.target) &&
-        props.closeOnClickOutside
-      ) {
+
+      let el = element && !element.contains(event.target);
+
+      if (props.targetId) {
+        const dom = document.querySelector(`#${props.targetId}`);
+        el = dom && !dom.contains(event.target);
+      }
+      if (el && elContent && !elContent.contains(event.target) && props.closeOnClickOutside) {
         closePopover();
       }
     };
@@ -159,8 +221,10 @@ export default create({
       closePopover,
       chooseItem,
       popoverRef,
-      getStyles,
-      popoverContentRef
+      popoverContentRef,
+      getRootPosition,
+      customStyle,
+      popoverArrowStyle
     };
   }
 });

+ 97 - 0
src/packages/__VUE/tour/__test__/popover.spec.ts

@@ -0,0 +1,97 @@
+import { config, mount } from '@vue/test-utils';
+import Popover from '../index.vue';
+import NutPupup from '../../popup/index.vue';
+import NutOverlay from '../../overlay/index.vue';
+import NutIcon from '../../icon/index.vue';
+import { nextTick, reactive } from 'vue';
+
+beforeAll(() => {
+  config.global.components = {
+    NutIcon,
+    NutPupup,
+    NutOverlay
+  };
+});
+
+afterAll(() => {
+  config.global.components = {};
+});
+
+const iconItemList = [{ name: 'option1' }, { name: 'option2' }, { name: 'option3' }];
+
+const listDisabled = reactive([
+  { name: 'option1', disabled: true },
+  { name: 'option2', disabled: true },
+  { name: 'option3' }
+]);
+
+test('first render', async () => {
+  const wrapper = mount(Popover, {
+    props: {
+      visible: true,
+      list: iconItemList,
+      teleportDisable: false
+    }
+  });
+  await nextTick();
+  expect(wrapper.find('.nut-popover-menu-item').exists()).toBeTruthy();
+});
+
+test('Props theme dark', async () => {
+  const wrapper = mount(Popover, {
+    props: {
+      visible: true,
+      list: iconItemList,
+      teleportDisable: false,
+      theme: 'dark'
+    }
+  });
+  await nextTick();
+  expect(wrapper.find('.nut-popover--dark').exists()).toBeTruthy();
+});
+
+test('should not emit select event when the action is disabled', async () => {
+  const wrapper = mount(Popover, {
+    props: {
+      visible: true,
+      list: listDisabled,
+      teleportDisable: false
+    }
+  });
+  await nextTick();
+  expect(wrapper.findAll('.nut-popover-menu-disabled').length).toEqual(2);
+
+  wrapper.find('.nut-popover-menu-item').trigger('click');
+  expect(wrapper.emitted('choose')).toBeFalsy();
+});
+
+test('should close popover when clicking the action', async () => {
+  const wrapper = mount(Popover, {
+    props: {
+      visible: true,
+      list: iconItemList,
+      teleportDisable: false
+    }
+  });
+  await nextTick();
+
+  await wrapper.find('.nut-popover-menu-item').trigger('click');
+  expect(wrapper.emitted('update:visible')![0]).toEqual([false]);
+
+  await wrapper.setProps({ closeOnClickAction: false });
+  await wrapper.find('.nut-popover-menu-item').trigger('click');
+  expect(wrapper.emitted('update:visible')).toHaveLength(1);
+});
+
+test('Set Props Position', async () => {
+  const wrapper = mount(Popover, {
+    props: {
+      visible: true,
+      list: iconItemList,
+      teleportDisable: false,
+      location: 'top-start'
+    }
+  });
+  await nextTick();
+  expect(wrapper.find('.nut-popover-arrow--top-start').exists()).toBeTruthy();
+});

+ 214 - 0
src/packages/__VUE/tour/demo.vue

@@ -0,0 +1,214 @@
+<template>
+  <div class="demo">
+    <h2>{{ translate('title') }}</h2>
+
+    <div class="music" @click="showTourHandle"> 新手引导 </div>
+
+    <div>
+      <div class="index-header"
+        ><img
+          src="https://img14.360buyimg.com/imagetools/jfs/t1/167902/2/8762/791358/603742d7E9b4275e3/e09d8f9a8bf4c0ef.png"
+          @click="showTour2 = true"
+        /><div class="info"><h1>NutUI</h1><p id="target6">京东风格的轻量级移动端 Vue 组件库</p></div></div
+      >
+    </div>
+
+    <nut-tabbar :bottom="true">
+      <nut-tabbar-item
+        id="target1"
+        tab-title="首页"
+        img="https://img13.360buyimg.com/imagetools/jfs/t1/23319/19/18329/3084/62e7c346E957c54ef/6c3e8a49e52b76f2.png"
+        activeImg="https://img11.360buyimg.com/imagetools/jfs/t1/70423/4/20553/3652/62e74629E23ba550e/aeeed0e3b9f43ae6.png"
+      ></nut-tabbar-item>
+      <nut-tabbar-item
+        id="target2"
+        tab-title="首页"
+        img="https://img13.360buyimg.com/imagetools/jfs/t1/202062/32/25149/5246/62e7c353E5a51db17/b82b940e6eb22ec3.png"
+        activeImg="https://img11.360buyimg.com/imagetools/jfs/t1/162634/35/26732/5502/62e747a8E5330f029/3ea00a0c140beb38.png"
+      ></nut-tabbar-item>
+      <nut-tabbar-item
+        id="target3"
+        tab-title="首页"
+        img="https://img12.360buyimg.com/imagetools/jfs/t1/60552/28/20576/5585/62e7c2ddE2e0b48a7/70eefb366b85f3e4.png"
+        activeImg="https://img11.360buyimg.com/imagetools/jfs/t1/138362/15/28011/5802/62e747a4E4139ef2f/9a79a1c0f6a273b4.png"
+      ></nut-tabbar-item>
+      <nut-tabbar-item
+        id="target4"
+        tab-title="首页"
+        img="https://img14.360buyimg.com/imagetools/jfs/t1/156023/30/29042/4257/62e7c34aE71f32967/690e2db242e2a97f.png"
+        activeImg="https://img13.360buyimg.com/imagetools/jfs/t1/144283/8/28420/4851/62e74784Eaa8549fe/80535de2961b812e.png"
+      ></nut-tabbar-item>
+    </nut-tabbar>
+
+    <nut-grid icon-size="18px">
+      <nut-grid-item icon="my2" text="文字"></nut-grid-item>
+      <nut-grid-item icon="star" text="文字"></nut-grid-item>
+      <nut-grid-item icon="dshop" text="文字"></nut-grid-item>
+      <nut-grid-item icon="shop" text="文字" id="target5"></nut-grid-item>
+    </nut-grid>
+
+    <nut-tour
+      class="nut-custom-tour"
+      v-model:visible="showTour"
+      :steps="steps"
+      location="top-start"
+      :offset="offset"
+    ></nut-tour>
+
+    <nut-tour
+      class="nut-custom-tour"
+      v-model:visible="showTour1"
+      :steps="steps1"
+      location="bottom-end"
+      type="tile"
+      bgColor="#f00"
+      theme="dark"
+      :offset="[0, 0]"
+    ></nut-tour>
+
+    <nut-tour
+      class="nut-custom-tour"
+      v-model:visible="showTour2"
+      :steps="steps2"
+      type="tile"
+      bgColor="#f00"
+      theme="dark"
+      location="bottom-start"
+    ></nut-tour>
+  </div>
+</template>
+<script lang="ts">
+import { reactive, ref, toRefs } from 'vue';
+import { createComponent } from '@/packages/utils/create';
+const { createDemo, translate } = createComponent('tour');
+import { useTranslate } from '@/sites/assets/util/useTranslate';
+
+const initTranslate = () =>
+  useTranslate({
+    'zh-CN': {
+      title: '基础用法',
+      title1: '选项配置',
+      title2: '自定义内容',
+      title3: '位置自定义',
+      light: '明朗风格',
+      dark: '暗黑风格',
+      showIcon: '展示图标',
+      disableAction: '禁用选项',
+      content: '自定义内容'
+    },
+    'en-US': {
+      title: 'Basic Usage',
+      title1: 'Option Configuration',
+      title2: 'Custom Content',
+      title3: 'Custom Location',
+      light: 'light',
+      dark: 'dark',
+      showIcon: 'show icon',
+      disableAction: 'disabled',
+      content: 'custom content'
+    }
+  });
+export default createDemo({
+  setup() {
+    initTranslate();
+
+    const state = reactive({
+      showTour: false,
+      showTour1: false,
+      showTour2: false,
+      offset: [-3, -8],
+      steps: [
+        {
+          content: '这里换成关注和粉丝啦,听歌时长点击头像可见',
+          target: 'target1'
+        },
+        {
+          content: '这里换成关注和粉丝啦,听歌时长点击头像可见',
+          target: 'target2'
+        },
+        {
+          content: '这里换成关注和粉丝啦,听歌时长点击头像可见',
+          target: 'target3',
+          location: 'top-end'
+        },
+        {
+          content: '这里换成关注和粉丝啦,听歌时长点击头像可见',
+          target: 'target4',
+          location: 'top-end'
+        }
+      ],
+
+      steps1: [
+        {
+          content: '邀请有礼,全新改版,奖励多多哦',
+          target: 'target5'
+        }
+      ],
+
+      steps2: [
+        {
+          content: '这里换成关注和粉丝啦,听歌时长点击头像可见',
+          target: 'target6'
+        }
+      ],
+
+      type: 'normal'
+    });
+
+    // setTimeout(() => {
+    //   state.showTour = true;
+    // }, 1000);
+
+    const showTourHandle = () => {
+      state.showTour1 = true;
+    };
+
+    return {
+      translate,
+      ...toRefs(state),
+      showTourHandle
+    };
+  }
+});
+</script>
+<style lang="scss">
+.music {
+  margin-bottom: 30px;
+}
+
+.nut-custom-tour {
+  .nut-popover-content {
+    width: auto !important;
+  }
+  .nut-tour-content-inner {
+    width: max-content;
+  }
+}
+
+.index-header {
+  display: flex;
+  align-items: center;
+  height: 117px;
+  > img {
+    width: 67px;
+    height: 67px;
+    margin-right: 18px;
+    flex-shrink: 0;
+  }
+  .info {
+    display: flex;
+    flex-direction: column;
+    h1 {
+      margin: 0;
+      height: 48px;
+      font-size: 34px;
+      color: rgba(51, 51, 51, 1);
+    }
+    p {
+      height: 18px;
+      font-size: 12px;
+      color: rgba(154, 155, 157, 1);
+    }
+  }
+}
+</style>

+ 333 - 0
src/packages/__VUE/tour/doc.en-US.md

@@ -0,0 +1,333 @@
+# Popover
+
+### Intro
+
+Click or hover over the element to pop up the bubble card overlay.
+
+### Install
+
+```javascript
+
+import { createApp } from 'vue';
+// vue
+import { Popover, Popup } from '@nutui/nutui';
+// taro
+import { Popover, Popup } from '@nutui/nutui-taro';
+
+const app = createApp();
+
+app.use(Popup);
+app.use(Popover);
+
+```
+
+
+### Basic Usage
+
+Popover supports both light and dark styles. The default is light style. Set the theme property to `dark` to switch to dark style.
+
+:::demo
+```html
+<template>
+  <nut-popover v-model:visible="visible.lightTheme" :list="iconItemList">
+    <template #reference>
+      <nut-button type="primary" shape="square">Light</nut-button>
+    </template>
+  </nut-popover>
+
+  <nut-popover v-model:visible="visible.darkTheme" theme="dark" :list="iconItemList">
+    <template #reference>
+      <nut-button type="primary" shape="square">Dark</nut-button>
+    </template>
+  </nut-popover>
+</template>
+<script>
+import { reactive, ref } from 'vue';
+export default {
+  setup() {
+    const visible = ref({
+      darkTheme: false,
+      lightTheme: false,
+    });
+    const iconItemList = reactive([
+      { name: 'option1' },
+      { name: 'option2' },
+      { name: 'option3' }
+    ]);
+    return {
+        visible,
+        iconItemList,
+      };
+    }
+}
+</script>
+
+```
+:::
+
+### Option Configuration
+
+In the list array, an option can be disabled via the `disabled` field.
+
+:::demo
+```html
+<template>
+  <nut-popover v-model:visible="visible.showIcon" theme="dark" :list="itemList">
+    <template #reference>
+      <nut-button type="primary" shape="square">Show Icon</nut-button>
+    </template>
+  </nut-popover>
+
+  <nut-popover v-model:visible="visible.disableAction" :list="itemListDisabled">
+    <template #reference>
+      <nut-button type="primary" shape="square">Disabled</nut-button>
+    </template>
+  </nut-popover>
+</template>
+
+<script>
+import { reactive, ref } from 'vue';
+export default {
+  setup() {
+    const visible = ref({
+      showIcon: false,
+      disableAction: false,
+    });
+
+    const itemList = reactive([
+      {
+        name: 'option1',
+        icon: 'my2'
+      },{
+        name: 'option2',
+        icon: 'cart2'
+      },{
+        name: 'option3',
+        icon: 'location2'
+      }
+    ]);
+
+    const itemListDisabled = reactive([
+      {
+        name: 'option1',
+        disabled: true
+      },{
+        name: 'option2',
+        disabled: true
+      },{
+        name: 'option3'
+      }
+    ]);
+
+    return {
+        itemList,
+        visible,
+        itemListDisabled,
+      };
+    }
+}
+</script>
+
+
+```
+:::
+
+
+### Custom Content
+
+Customize the content in the slot named content.
+
+:::demo
+```html
+<template>
+  <nut-popover v-model:visible="visible.Customized">
+    <template #reference>
+      <nut-button type="primary" shape="square">custom content</nut-button>
+    </template>
+
+    <template #content>
+      <div class="self-content">
+        <div class="self-content-item" v-for="(item, index) in selfContent" :key="index">
+          <nut-icon :name="item.name" size="15"></nut-icon>
+        <div class="self-content-desc">{{ item.desc }}</div>
+        </div>
+      </div>
+    </template>
+  </nut-popover>
+</template>
+
+
+<script>
+import { reactive, ref } from 'vue';
+export default {
+  setup() {
+    const visible = ref({
+      Customized: false,
+    });
+    const selfContent = reactive([
+      {
+        name: 'service',
+        desc: 'option1'
+      },
+      {
+        name: 'notice',
+        desc: 'option2'
+      },
+      {
+        name: 'location',
+        desc: 'option3'
+      },
+      {
+        name: 'category',
+        desc: 'option4'
+      },
+      {
+        name: 'scan2',
+        desc: 'option5'
+      },
+      {
+        name: 'message',
+        desc: 'option6'
+      }
+    ]);
+
+    return {
+      visible,
+      selfContent,
+    };
+  }
+}
+</script>
+
+<style lang="scss">
+.self-content {
+  width: 195px;
+  display: flex;
+  flex-wrap: wrap;
+  &-item {
+    margin-top: 10px;
+    margin-bottom: 10px;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    flex-direction: column;
+  }
+  &-desc {
+    margin-top: 5px;
+    width: 60px;
+    font-size: 10px;
+    text-align: center;
+  }
+}
+</style>
+
+
+```
+:::
+
+### Placement
+
+Use the location property to control where the bubble pops up. optional value
+
+```
+top           # Top middle 
+left          # Left middle 
+right         # Right middle 
+bottom        # Bottom middle 
+```
+New since `v3.1.21`
+```
+top-start     # Top left
+top-end       # Top right 
+left-start    # Left top
+left-end      # Left bottom
+right-start   # Right top
+right-end     # Right bottom
+bottom-start  # Bottom left
+bottom-end    # Bottom right
+```
+
+
+:::demo
+```html
+<template>
+  <nut-popover v-model:visible="visible" location="top" theme="dark" :list="iconItemList">
+    <template #reference>
+      <div class="brick"></div>
+    </template>
+  </nut-popover>
+</template>
+
+<script lang="ts">
+import { reactive, ref } from 'vue';
+export default {
+  setup() {
+    const visible = ref(false);
+
+    const iconItemList = reactive([
+        {
+          name: 'option1'
+        },
+        {
+          name: 'option2'
+        }]);
+
+      return {
+        iconItemList,
+        visible,
+      };
+    }
+};
+</script>
+
+```
+:::
+
+
+## API
+### Props  
+
+| Attribute | Description | Type | Default |
+|----------------|---------------------------------|---------|------------|
+| list          | list of options                          | List[]   | []        |
+| visible      | whether to show                 | boolean  | false     |
+| theme          | Theme style, can be set to `dark` `light`          | string   | `light`   |
+| location       | pop-up location  | string   | `bottom`  |
+| offset `v3.1.21`       | the offset of the occurrence position  | [number, number]   | [0, 12]  |
+| show-arrow `v3.1.21`       | whether to show small arrows  | boolean  | true  |
+| custom-class `v3.1.21`       | custom class   | string  | ''  |
+| duration `v3.1.21`       | Transition duration  |  [number, number]  | 0.3  |
+| iconPrefix `v3.1.21`       | Icon className prefix | string  | 'nut-icon''  |
+| overlay `v3.2.8`       | Whether to show overlay  | Boolean  | false  |
+| overlay-class `v3.2.8`       | Custom overlay class | string  | ''  |
+| overlay-style `v3.2.8`       | Custom overlay style  | string  | ''  |
+| close-on-click-overlay `v3.2.8`       | Whether to close when clicking overlay  | boolean  | true  |
+| close-on-click-action `v3.2.8`       | Whether to close when clicking action  | boolean  | true |
+| close-on-click-outside `v3.2.8`       | Whether to close when clicking outside | boolean  | true  |
+
+### List data structure  
+
+The List property is an array of objects, each object in the array is configured with a column, and the object can contain the following values:
+
+| Key            | Description                 | Type      | Default  |
+|----------------|----------------------|----------|--------|
+| name           | option text               | string   | -      |
+| icon           | `nut-icon` name      | string   | -      |
+| disabled       | whether to disable          | boolean  | false  | 
+| className `v3.1.21`      | Add extra class names for corresponding options          | string/Array/object  | -  | 
+
+
+### Slots
+
+| Name   | Description           |
+|---------|--------------|
+| content | Customize Bubble Component Menu Content |
+| reference | The content of the element that triggers the Popover to display |
+
+### Events
+
+| Event     | Description    | 
+|---------|--------------|
+| choose | Triggered when an option is clicked |
+| open   | Triggered when the menu is opened |
+| close  | Fired when the menu is closed |

+ 332 - 0
src/packages/__VUE/tour/doc.md

@@ -0,0 +1,332 @@
+# Popover 气泡弹出框
+
+### 介绍
+
+点击或在元素上悬停鼠标,弹出气泡卡片浮层。
+
+### 安装
+
+```javascript
+
+import { createApp } from 'vue';
+// vue
+import { Popover, Popup } from '@nutui/nutui';
+// taro
+import { Popover, Popup } from '@nutui/nutui-taro';
+
+const app = createApp();
+
+app.use(Popup);
+app.use(Popover);
+
+```
+
+
+### 基础用法
+
+Popover 支持明朗和暗黑两种风格,默认为明朗风格,将 theme 属性设置为 dark 可切换为暗黑风格。
+
+:::demo
+```html
+<template>
+  <nut-popover v-model:visible="visible.lightTheme" :list="iconItemList">
+    <template #reference>
+      <nut-button type="primary" shape="square">明朗风格</nut-button>
+    </template>
+  </nut-popover>
+
+  <nut-popover v-model:visible="visible.darkTheme" theme="dark" :list="iconItemList">
+    <template #reference>
+      <nut-button type="primary" shape="square">暗黑风格</nut-button>
+    </template>
+  </nut-popover>
+</template>
+<script>
+import { reactive, ref } from 'vue';
+export default {
+  setup() {
+    const visible = ref({
+      darkTheme: false,
+      lightTheme: false,
+    });
+    const iconItemList = reactive([
+      { name: '选项一' },
+      { name: '选项二' },
+      { name: '选项三' }
+    ]);
+    return {
+        visible,
+        iconItemList,
+      };
+    }
+}
+</script>
+
+```
+:::
+
+### 选项配置
+
+在 list 数组中,可以通过 disabled 字段来禁用某个选项。
+
+:::demo
+```html
+<template>
+  <nut-popover v-model:visible="visible.showIcon" theme="dark" :list="itemList">
+    <template #reference>
+      <nut-button type="primary" shape="square">展示图标</nut-button>
+    </template>
+  </nut-popover>
+
+  <nut-popover v-model:visible="visible.disableAction" :list="itemListDisabled">
+    <template #reference>
+      <nut-button type="primary" shape="square">禁用选项</nut-button>
+    </template>
+  </nut-popover>
+</template>
+
+<script>
+import { reactive, ref } from 'vue';
+export default {
+  setup() {
+    const visible = ref({
+      showIcon: false,
+      disableAction: false,
+    });
+
+    const itemList = reactive([
+      {
+        name: '选项一',
+        icon: 'my2'
+      },{
+        name: '选项二',
+        icon: 'cart2'
+      },{
+        name: '选项三',
+        icon: 'location2'
+      }
+    ]);
+
+    const itemListDisabled = reactive([
+      {
+        name: '选项一',
+        disabled: true
+      },{
+        name: '选项二',
+        disabled: true
+      },{
+        name: '选项三'
+      }
+    ]);
+
+    return {
+        itemList,
+        visible,
+        itemListDisabled,
+      };
+    }
+}
+</script>
+
+
+```
+:::
+
+
+### 自定义内容
+
+在名为 content 插槽中自定义内容。
+
+:::demo
+```html
+<template>
+  <nut-popover v-model:visible="visible.Customized">
+    <template #reference>
+      <nut-button type="primary" shape="square">自定义内容</nut-button>
+    </template>
+
+    <template #content>
+      <div class="self-content">
+        <div class="self-content-item" v-for="(item, index) in selfContent" :key="index">
+          <nut-icon :name="item.name" size="15"></nut-icon>
+        <div class="self-content-desc">{{ item.desc }}</div>
+        </div>
+      </div>
+    </template>
+  </nut-popover>
+</template>
+
+
+<script>
+import { reactive, ref } from 'vue';
+export default {
+  setup() {
+    const visible = ref({
+      Customized: false,
+    });
+    const selfContent = reactive([
+      {
+        name: 'service',
+        desc: '选项一'
+      },
+      {
+        name: 'notice',
+        desc: '选项二'
+      },
+      {
+        name: 'location',
+        desc: '选项三'
+      },
+      {
+        name: 'category',
+        desc: '选项四'
+      },
+      {
+        name: 'scan2',
+        desc: '选项五'
+      },
+      {
+        name: 'message',
+        desc: '选项六'
+      }
+    ]);
+
+    return {
+      visible,
+      selfContent,
+    };
+  }
+}
+</script>
+
+<style lang="scss">
+.self-content {
+  width: 195px;
+  display: flex;
+  flex-wrap: wrap;
+  &-item {
+    margin-top: 10px;
+    margin-bottom: 10px;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    flex-direction: column;
+  }
+  &-desc {
+    margin-top: 5px;
+    width: 60px;
+    font-size: 10px;
+    text-align: center;
+  }
+}
+</style>
+
+
+```
+:::
+
+### 位置自定义
+
+通过 location 属性来控制气泡的弹出位置。可选值
+```
+top           # 顶部中间位置
+left          # 左侧中间位置
+right         # 右侧中间位置
+bottom        # 底部中间位置
+```
+自 `v3.1.21` 起新增
+```
+top-start     # 顶部左侧位置
+top-end       # 顶部右侧位置 
+left-start    # 左侧上方位置
+left-end      # 左侧下方位置
+right-start   # 右侧上方位置
+right-end     # 右侧下方位置
+bottom-start  # 底部左侧位置
+bottom-end    # 底部右侧位置
+```
+
+
+:::demo
+```html
+<template>
+  <nut-popover v-model:visible="visible" location="top" theme="dark" :list="iconItemList">
+    <template #reference>
+      <div class="brick"></div>
+    </template>
+  </nut-popover>
+</template>
+
+<script lang="ts">
+import { reactive, ref } from 'vue';
+export default {
+  setup() {
+    const visible = ref(false);
+
+    const iconItemList = reactive([
+        {
+          name: '选项一'
+        },
+        {
+          name: '选项二'
+        }]);
+
+      return {
+        iconItemList,
+        visible,
+      };
+    }
+};
+</script>
+
+```
+:::
+
+
+## API
+### Props  
+
+| 字段            | 说明                            | 类型     | 默认值      |
+|----------------|---------------------------------|---------|------------|
+| list          | 选项列表                          | List[]   | []        |
+| visible      | 是否展示气泡弹出层                 | boolean  | false     |
+| theme          | 主题风格,可选值为 dark            | string   | `light`   |
+| location       | 弹出位置  | string   | `bottom`  |
+| offset `v3.1.21`       | 出现位置的偏移量  | [number, number]   | [0, 12]  |
+| show-arrow `v3.1.21`       | 是否显示小箭头  | boolean  | true  |
+| custom-class `v3.1.21`       | 自定义 class 值  | string  | ''  |
+| duration `v3.1.21`       | 动画时长  |  [number, number]  | 0.3  |
+| iconPrefix `v3.1.21`       | 图标自定义类值,等同于 Icon 组件的[ class-prefix 属性](https://nutui.jd.com/#/zh-CN/component/icon)  | string  | 'nut-icon''  |
+| overlay `v3.2.8`       | 是否显示遮罩层  | Boolean  | false  |
+| overlay-class `v3.2.8`       | 自定义遮罩层类名 | string  | ''  |
+| overlay-style `v3.2.8`       | 自定义遮罩层样式  | string  | ''  |
+| close-on-click-overlay `v3.2.8`       | 是否在点击遮罩层后关闭菜单  | boolean  | true  |
+| close-on-click-action `v3.2.8`       | 是否在点击选项后关闭  | boolean  | true |
+| close-on-click-outside `v3.2.8`       | 是否在点击外部元素后关闭菜单 | boolean  | true  |
+
+### List 数据结构  
+
+List 属性是一个由对象构成的数组,数组中的每个对象配置一列,对象可以包含以下值:
+
+| 键名            | 说明                 | 类型      | 默认值  |
+|----------------|----------------------|----------|--------|
+| name           | 选项文字               | string   | -      |
+| icon           | nut-icon 图标名称      | string   | -      |
+| disabled       | 是否为禁用状态          | boolean  | false  | 
+| className `v3.1.21`      | 为对应选项添加额外的类名          | string/Array/object  | -  | 
+
+
+### Slots
+
+| 名称    | 说明         |
+|---------|--------------|
+| content | 自定义气泡组件菜单内容 |
+| reference | 触发 Popover 显示的元素内容 |
+
+### Events
+
+| 名称    | 说明         |
+|---------|--------------|
+| choose | 点击选项时触发 |
+| open   | 打开菜单时触发 |
+| close  | 关闭菜单时触发 |

+ 72 - 0
src/packages/__VUE/tour/index.scss

@@ -0,0 +1,72 @@
+.nut-tour {
+  &-mask {
+    position: fixed;
+
+    width: 100px;
+    height: 50px;
+    box-shadow: 0px 0px 0px 100vh rgba(0, 0, 0, 0.5);
+    border-radius: 10px;
+
+    z-index: 1002;
+
+    &-none {
+      box-shadow: none;
+    }
+  }
+
+  &-content {
+    padding: 10px 12px;
+    min-width: 200px;
+
+    &-top {
+      text-align: right;
+    }
+
+    &-inner {
+      margin: 10px 0px;
+    }
+
+    &-bottom {
+      margin-top: 10px;
+      display: flex;
+      justify-content: space-between;
+      &-init {
+        margin-left: 10px;
+      }
+      &-operate {
+        display: flex;
+        justify-content: end;
+        &-btn {
+          display: inline-block;
+          border: 1px solid $disable-color;
+          margin-left: 4px;
+          padding: 2px 4px;
+          font-size: 12px;
+          border-radius: 4px;
+          color: $text-color;
+          &.active {
+            color: #fff;
+            border: 0;
+            background: $primary-color;
+          }
+        }
+      }
+    }
+
+    &-tile {
+      .nut-tour-content-inner {
+        margin: 0;
+      }
+    }
+  }
+
+  &-masked {
+    position: fixed;
+    width: 100vh;
+    height: 100vh;
+    z-index: 999;
+    top: 0;
+    left: 0;
+    background: transparent;
+  }
+}

+ 201 - 0
src/packages/__VUE/tour/index.taro.vue

@@ -0,0 +1,201 @@
+<template>
+  <view :class="['nut-popover', `nut-popover--${theme}`, `${customClass}`]">
+    <view class="nut-popover-wrapper" @click="openPopover" ref="popoverRef" :id="'popoverRef' + refRandomId"
+      ><slot name="reference"></slot
+    ></view>
+
+    <nut-popup
+      :popClass="`nut-popover-content nut-popover-content--${location}`"
+      :style="getStyles"
+      v-model:visible="showPopup"
+      position=""
+      transition="nut-popover"
+      :overlay="overlay"
+      :duration="duration"
+      :overlayStyle="overlayStyle"
+      :overlayClass="overlayClass"
+      :closeOnClickOverlay="closeOnClickOverlay"
+    >
+      <view ref="popoverContentRef" :id="'popoverContentRef' + refRandomId" class="nut-popover-content-group">
+        <view :class="popoverArrow" v-if="showArrow"> </view>
+        <slot name="content"></slot>
+        <view
+          v-for="(item, index) in list"
+          :key="index"
+          :class="[
+            item.className,
+            item.disabled && 'nut-popover-menu-disabled',
+            'nut-popover-menu-item',
+            'nut-popover-menu-taroitem'
+          ]"
+          @click.stop="chooseItem(item, index)"
+        >
+          <slot v-if="item.icon">
+            <nut-icon
+              v-bind="$attrs"
+              class="nut-popover-item-img"
+              :classPrefix="iconPrefix"
+              :name="item.icon"
+            ></nut-icon
+          ></slot>
+          <view class="nut-popover-menu-item-name">{{ item.name }}</view>
+        </view>
+      </view>
+    </nut-popup>
+
+    <view class="nut-popover-content-bg" v-if="showPopup" @touchmove="clickAway" @click="clickAway"></view>
+  </view>
+</template>
+<script lang="ts">
+import { onMounted, computed, watch, ref, PropType, toRefs, reactive, CSSProperties } from 'vue';
+import { createComponent } from '@/packages/utils/create';
+const { componentName, create } = createComponent('popover');
+import { useTaroRect } from '@/packages/utils/useTaroRect';
+import { isArray } from '@/packages/utils/util';
+import Taro from '@tarojs/taro';
+
+export default create({
+  inheritAttrs: false,
+  components: {},
+  props: {
+    visible: { type: Boolean, default: false },
+    list: { type: Array, default: [] },
+    theme: { type: String as PropType<import('./type').PopoverTheme>, default: 'light' },
+    location: { type: String as PropType<import('./type').PopoverLocation>, default: 'bottom' },
+    offset: { type: Array, default: [0, 12] },
+    customClass: { type: String, default: '' },
+    showArrow: { type: Boolean, default: true },
+    iconPrefix: { type: String, default: 'nut-icon' },
+    duration: { type: [Number, String], default: 0.3 },
+    overlay: { type: Boolean, default: false },
+    overlayClass: { type: String, default: '' },
+    overlayStyle: { type: Object as PropType<CSSProperties> },
+    closeOnClickOverlay: { type: Boolean, default: true },
+    closeOnClickAction: { type: Boolean, default: true },
+    closeOnClickOutside: { type: Boolean, default: true }
+  },
+  emits: ['update', 'update:visible', 'close', 'choose', 'open'],
+  setup(props, { emit }) {
+    const popoverRef = ref();
+    const popoverContentRef = ref();
+    const showPopup = ref(props.visible);
+    const state = reactive({
+      rootWidth: 0,
+      rootHeight: 0
+    });
+
+    const popoverArrow = computed(() => {
+      const prefixCls = 'nut-popover-arrow';
+      const loca = props.location;
+      const direction = loca.split('-')[0];
+      return `${prefixCls} ${prefixCls}-${direction} ${prefixCls}--${loca}`;
+    });
+    const getStyles = computed(() => {
+      let cross = +state.rootHeight;
+      let lengthways = +state.rootWidth;
+      let { offset, location } = props;
+      if (isArray(offset) && offset.length == 2) {
+        cross += +offset[1];
+        lengthways += +offset[1];
+      }
+      const direction = location.split('-')[0];
+      const style: CSSProperties = {};
+      const mapd: any = {
+        top: 'bottom',
+        bottom: 'top',
+        left: 'right',
+        right: 'left'
+      };
+      if (['top', 'bottom'].includes(direction)) {
+        style[mapd[direction]] = `${cross}px`;
+        style.marginLeft = `${offset[0]}px`;
+      } else {
+        style[mapd[direction]] = `${lengthways}px`;
+        style.marginTop = `${offset[0]}px`;
+      }
+      return style;
+    });
+    // 获取宽度
+    const getContentWidth = async () => {
+      const refe = await useTaroRect(popoverRef, Taro);
+      const { height, width } = refe;
+      state.rootHeight = height;
+      state.rootWidth = width;
+    };
+    watch(
+      () => props.visible,
+      (value) => {
+        showPopup.value = value;
+        if (value) {
+          setTimeout(() => {
+            getContentWidth();
+          }, 200);
+        }
+      }
+    );
+    const update = (val: boolean) => {
+      emit('update', val);
+      emit('update:visible', val);
+    };
+    const openPopover = () => {
+      update(!props.visible);
+      emit('open');
+    };
+    const closePopover = () => {
+      emit('update:visible', false);
+      emit('close');
+    };
+    const chooseItem = (item: any, index: number) => {
+      emit('choose', item, index);
+      if (props.closeOnClickAction) {
+        closePopover();
+      }
+    };
+    const clickAway = (event: Event) => {
+      closePopover();
+    };
+
+    onMounted(() => {
+      setTimeout(() => {
+        getContentWidth();
+      }, 200);
+    });
+
+    const refRandomId = Math.random().toString(36).slice(-8);
+
+    return {
+      showPopup,
+      openPopover,
+      popoverArrow,
+      closePopover,
+      chooseItem,
+      popoverRef,
+      getStyles,
+      popoverContentRef,
+      refRandomId,
+      clickAway
+    };
+  }
+});
+</script>
+<style lang="scss">
+.self-content {
+  width: 195px;
+  display: flex;
+  flex-wrap: wrap;
+  &-item {
+    margin-top: 10px;
+    margin-bottom: 10px;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    flex-direction: column;
+  }
+  &-desc {
+    margin-top: 5px;
+    width: 60px;
+    font-size: 10px;
+    text-align: center;
+  }
+}
+</style>

+ 201 - 0
src/packages/__VUE/tour/index.vue

@@ -0,0 +1,201 @@
+<template>
+  <div :class="classes">
+    <div class="nut-tour-masked" v-if="showTour"></div>
+    <div v-for="(step, i) in steps" :key="i">
+      <template v-if="i == active">
+        <div
+          class="nut-tour-mask"
+          :class="[mask ? '' : 'nut-tour-mask-none']"
+          :style="maskStyle"
+          v-if="showTour"
+          id="nut-tour-popid"
+        ></div>
+        <nut-popover
+          v-model:visible="showPopup"
+          :location="step.location || location"
+          targetId="nut-tour-popid"
+          :bgColor="bgColor"
+          :theme="theme"
+          :close-on-click-outside="false"
+        >
+          <template #content>
+            <div class="nut-tour-content" v-if="type == 'step'">
+              <div class="nut-tour-content-top">
+                <div class="nut-tour-content-top-close" @click="close">
+                  <Icon size="10" name="close"> </Icon>
+                </div>
+              </div>
+              <div class="nut-tour-content-inner">
+                {{ step.content }}
+              </div>
+              <div class="nut-tour-content-bottom">
+                <div class="nut-tour-content-bottom-init">{{ active + 1 }}/{{ steps.length }}</div>
+                <div class="nut-tour-content-bottom-operate">
+                  <div class="nut-tour-content-bottom-operate-btn" @click="changeStep('prev')" v-if="active != 0">{{
+                    prevStepTxt
+                  }}</div>
+                  <div
+                    class="nut-tour-content-bottom-operate-btn active"
+                    @click="close"
+                    v-if="steps.length - 1 == active"
+                    >{{ completeTxt }}</div
+                  >
+                  <div class="nut-tour-content-bottom-operate-btn active" @click="changeStep('next')" v-else>{{
+                    nextStepTxt
+                  }}</div>
+                </div>
+              </div>
+            </div>
+
+            <div class="nut-tour-content nut-tour-content-tile" v-if="type == 'tile'">
+              <div class="nut-tour-content-inner">
+                {{ step.content }}
+              </div>
+            </div>
+          </template>
+        </nut-popover>
+      </template>
+    </div>
+  </div>
+</template>
+<script lang="ts">
+import { computed, watch, ref, reactive, toRefs, PropType, nextTick } from 'vue';
+import { PopoverLocation } from '../popover/type';
+import { createComponent } from '@/packages/utils/create';
+import { useRect } from '@/packages/utils/useRect';
+import { Icon } from '@nutui/icons-vue';
+
+interface StepOptions {
+  target: Element;
+  content: String;
+  location: PopoverLocation;
+}
+const { create } = createComponent('tour');
+export default create({
+  components: {
+    Icon
+  },
+  props: {
+    visible: { type: Boolean, default: false },
+    type: {
+      type: String,
+      default: 'step' // tile
+    },
+    steps: {
+      type: Array as PropType<StepOptions[]>,
+      default: () => []
+    },
+    location: {
+      type: String as PropType<PopoverLocation>,
+      default: 'bottom'
+    },
+    current: {
+      type: Number,
+      default: 0
+    },
+    nextStepTxt: {
+      type: String,
+      default: '下一步'
+    },
+    prevStepTxt: {
+      type: String,
+      default: '上一步'
+    },
+    completeTxt: {
+      type: String,
+      default: '完成'
+    },
+    mask: {
+      type: Boolean,
+      default: true
+    },
+    offset: {
+      type: Array as PropType<Number[]>,
+      default: [8, 10]
+    },
+    bgColor: {
+      type: String,
+      default: ''
+    },
+    theme: {
+      type: String,
+      default: 'light'
+    }
+  },
+  emits: ['update:visible', 'change', 'close'],
+  setup(props, { emit }) {
+    const state = reactive({
+      showTour: props.visible,
+      showPopup: false,
+      active: 0
+    });
+
+    const maskRect = ref<any>({});
+
+    const classes = computed(() => {
+      const prefixCls = 'nut-tour';
+      return `${prefixCls}`;
+    });
+
+    const maskStyle = computed(() => {
+      const { offset } = props;
+      const { width, height, left, top } = maskRect.value;
+      const styles = {
+        width: `${width + offset[1] * 2}px`,
+        height: `${height + offset[0] * 2}px`,
+        top: `${top - offset[0]}px`,
+        left: `${left - offset[1]}px`
+      };
+      return styles;
+    });
+
+    const changeStep = (type: string) => {
+      if (type == 'next') {
+        state.active = state.active + 1;
+      } else {
+        state.active = state.active - 1;
+      }
+
+      state.showPopup = false;
+      nextTick(() => {
+        state.showPopup = true;
+        getRootPosition();
+      });
+
+      emit('change', state.active);
+    };
+
+    const getRootPosition = () => {
+      const el: any = document.querySelector(`#${props.steps[state.active].target}`);
+      const rect = useRect(el);
+      maskRect.value = rect;
+    };
+
+    const close = () => {
+      state.showTour = false;
+      state.showPopup = false;
+      emit('update:visible', false);
+    };
+
+    watch(
+      () => props.visible,
+      (val) => {
+        state.showTour = val;
+
+        if (val) {
+          state.showPopup = true;
+          getRootPosition();
+        }
+      }
+    );
+
+    return {
+      ...toRefs(state),
+      classes,
+      maskStyle,
+      changeStep,
+      close
+    };
+  }
+});
+</script>

+ 15 - 0
src/packages/__VUE/tour/type.ts

@@ -0,0 +1,15 @@
+export type PopoverTheme = 'light' | 'dark';
+
+export type PopoverLocation =
+  | 'bottom'
+  | 'top'
+  | 'left'
+  | 'right'
+  | 'top-start'
+  | 'top-end'
+  | 'bottom-start'
+  | 'bottom-end'
+  | 'left-start'
+  | 'left-end'
+  | 'right-start'
+  | 'right-end';

+ 10 - 3
src/packages/utils/useRect/index.ts

@@ -14,9 +14,16 @@ function isWindow(val: unknown): val is Window {
   return val === window;
 }
 
-export const useRect = (
-  elementRef: (Element | Window) | Ref<Element | Window | undefined>
-) => {
+export interface rect {
+  top: number;
+  left: number;
+  right: number;
+  bottom: number;
+  width: number;
+  height: number;
+}
+
+export const useRect = (elementRef: (Element | Window) | Ref<Element | Window | undefined>) => {
   const element = unref(elementRef);
 
   if (isWindow(element)) {