uniapp页面新手引导

1: 效果展示

2: 页面使用

html 复制代码
<template>   
 <!-- 新手引导 -->
    <BKTour :open.sync="tourOpen" :steps="steps" :onFinish="handleTourFinish" :Z_INDEX="90000" :Z_INDEX_MASK="80000">
      <template #default="{ step, bound, zIndex, onNext }">
        <TourOperate :id="step.id" :bound="bound" :zIndex="zIndex" :onNext="onNext" />
      </template>
    </BKTour>
</template>
<script>
export default {
  data() {
    return {
      tourOpen: false, // 是否展示新手引导
      steps: [], // 新手引导步骤
      needNoviceGuide: uni.getStorageSync('NEED_NOVICE_GUIDE_SUBJECT') // 新手指南是否已经展示过标识【新手引导只展示一次】
    }
  },
  methods: {
    // 显示新手引导
    showTour() {
      if (!this.needNoviceGuide) {
        return
      }
      setTimeout(() => {
        uni.pageScrollTo({ scrollTop: 0, duration: 0 })
        this.steps = this.initTourSteps()
        this.tourOpen = true
      }, 500)
    },
    // 初始化新手引导步骤对象obj
    initTourSteps() {
      return [
        // 键盘操作说明
        {
          id: 'keyboardOperateInstructionRef', // 需要新手引导步骤的元素id
          target: () => ({
            tourClose: () => {
              this.keyboardOperateInstructionBoxStyle = {}
            },
            tourOpen: (zIndex) =>
              (new Promise((resolve, reject) => {
                uni
                  .createSelectorQuery()
                  .select(`.keyboard_scope`) //.keyboard_scope是引导步骤的元素class,获取改元素的坐标等信息
                  .boundingClientRect((res) => {
                    //
                    if (res) {
                      this.keyboardOperateInstructionBoxStyle = { zIndex, background: '#FFF', borderRadius: '10px' }
                      resolve(res)
                    } else {
                      reject(new Error(`keyboard_scope 未找到元素`))
                    }
                  })
                  .exec()
              }))
          }),
          show: true
        }
      ].filter((item) => item.show !== false)
    },
     // 最后一步的回调方法
     async handleTourFinish() {
       this.needNoviceGuide = false // 关闭新手引导
       uni.setStorageSync('NEED_NOVICE_GUIDE_SUBJECT', false) // 设置新手引导已被触发标识
     },
  }
};
</script>

3: 新手引导弹窗组件

TourOperate组件(需更具需要自定义元素步骤)

html 复制代码
<template>
  <view class="tourOperateStyle" :style="{ zIndex: props.zIndex }">
    <!--键盘操作说明 -->
    <view
      v-if="props.id === 'keyboardOperateInstructionRef'"
      class="step step0"
      :style="{
        top: `${props.bound.bottom}px`,
        left: `${props.bound.left}px`
      }"
    >
      <image class="icon_tour_operate_finger" src="https://wwxq.wanweiedu.com/static/home/2025-03-28/icon_tour_operate_finger.png" />
      <view class="line" />
      <view class="content">
        <view class="wrapper">
          <image
            class="icon_tour_operate_step0"
            src="https://wwxq.wanweiedu.com/static/keyboard/icon_tour_operate_step0.png"
            mode="heightFix"
          />
          <!-- <view class="text">随时为你解决学习困惑!</view> -->
        </view>

        <view class="button" @click="props.onNext">我知道了</view>
      </view>
    </view>
  </view>
</template>

<script lang="ts" setup>
import { usePad } from '@/@wmeimob/hooks/usePad'
import { divide } from 'number-precision'

interface ITourOperateProps {
  /** 标记 */
  id: string
  /** 层级 */
  zIndex: number
  /** 目标位置信息 */
  bound: UniApp.NodeInfo
  /** 下一步 */
  onNext(): void
}

const props = withDefaults(defineProps<ITourOperateProps>(), {})

const { isPad } = usePad()

/** rpx 转换到 px 的最大支持宽度,超过后不再进行转换, 768 - 1*/
const rpxMaxWidth = 767


/** 超过这个屏幕宽度即认定为 Pad 竖屏方向, 768 - 1*/
const padVerticalWidth = 767


/** 超过这个屏幕宽度即认定为 Pad 横屏方向, 1024 - 1*/
const padHorizontalWidth = 1023

/**
 * 设计稿转实际尺寸
 * @param rpx 750设计稿尺寸对应的尺寸
 */
const rpxToPx => (rpx: number) {
  const systemInfo = uni.getSystemInfoSync()
  const { windowWidth } = systemInfo

  if (windowWidth >= rpxMaxWidth) {
    return divide(rpx, 2)
  }

  const ratio = systemInfo.windowWidth / 750
  return Math.floor(rpx * ratio)
}
</script>

<style lang="scss" scoped>
 @import '~@wmeimob/style/mixins.scss';

.tourOperateStyle {
  position: fixed;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
}

.line {
  width: 4rpx;
  height: 103rpx;
  background-image: linear-gradient(180deg, #fff 0%, rgba(255, 255, 255, 0.5) 100%);
}

.step {
  position: absolute;
  width: 686rpx;
}

.step0 {
  .icon_tour_operate_finger {
    position: absolute;
    left: 128rpx;
    top: 6rpx;
    width: 84rpx;
    height: 92rpx;
  }

  .line {
    margin-left: 62rpx;
  }

  .icon_tour_operate_step0 {
    display: block;
    // height: 48rpx;
    height: 32rpx;
    margin-bottom: 2rpx;
  }
}

.step1 {
  .line {
    margin-left: 62rpx;
  }
}

.step2 {
  transform: translateY(-100%);

  .line {
    margin-left: 108rpx;
  }
}

.step3 {
  bottom: 60px; // tabbar 高度

  .line {
    position: fixed;
    bottom: 60px;
  }

  .content {
    margin-bottom: 103rpx;
  }

  .text {
    white-space: nowrap;
  }

  .button {
    margin: 0;
  }
}

.content {
  display: flex;
  justify-content: space-between;
  align-items: center;
  min-height: 122rpx;
  background: linear-gradient(90deg, #438af6 0%, #58befb 100%);
  border-radius: 24rpx;
  box-sizing: border-box;
  padding: 24rpx;
}

.text {
  font-family: Alibaba PuHuiTi, Alibaba PuHuiTi;
  font-weight: 500;
  font-size: 28rpx;
  color: #ffffff;
  line-height: 48rpx;
}

.button {
  flex: none;
  display: flex;
  justify-content: center;
  align-items: center;
  width: 152rpx;
  height: 58rpx;
  background: #ffffff;
  border-radius: 48rpx;

  font-family: Alibaba PuHuiTi, Alibaba PuHuiTi;
  font-weight: 500;
  font-size: 26rpx;
  color: #448bf6;
  line-height: 48rpx;

  margin-left: 24rpx;
}

</style>

4: 新手引导公共组件

BKTour.vue

html 复制代码
<template>
  <view class="tourStyle">
    <BKOverlay :show="overlay" :zIndexMask="Z_INDEX_MASK"/>
    <view v-if="overlay" class="tour_mask" :style="{ zIndex: props.Z_INDEX + 10 }" @touchmove.stop.prevent="handleTouchMove">
      <slot
        v-if="overlay"
        :current="index"
        :step="computedCurrentStep()"
        :bound="currentBound"
        :zIndex="props.Z_INDEX + 12"
        :isFinish="computedIsFinished()"
        :onNext="() => handleNextStep(index + 1)"
      />
    </view>
  </view>
</template>

<script lang="ts" setup>
import { useSuperLock } from '@/@wmeimob/hooks/useSuperLock'
import { ref, watch } from '@vue/composition-api'

interface ITourStep {
  id: string
  target(): {
    tourOpen(zIndex: number): Promise<UniApp.NodeInfo>
    tourClose(): void
  }
}

interface ITourProps {
  open?: boolean
  steps: ITourStep[]
  onFinish?(): void
  Z_INDEX?: any
  Z_INDEX_MASK?: any
}

const props = withDefaults(defineProps<ITourProps>(), {
  steps: () => [],
  Z_INDEX: 9000
})

const emit = defineEmits<{
  'update:open': [show: boolean]
}>()

const overlay = ref(false)
const index = ref(-1)
const currentBound = ref<UniApp.NodeInfo>({})

const computedCurrentStep = () => props.steps[index.value]

const computedIsFinished = () => props.open && index.value === props.steps.length - 1

// const Z_INDEX = 9000


const [handleNextStep] = useSuperLock((nextIndex: number) => {
  const hadNext = !!props.steps[nextIndex]

  if (!hadNext) {
    props.steps.forEach((step) => step.target()?.tourClose?.())
    emit('update:open', false)
    props.onFinish?.()
    return
  }

  index.value = nextIndex

  props.steps.forEach(async (step, idx) => {
    if (idx !== index.value) {
      step.target?.()?.tourClose()
      return
    }

    if (step.target) {
      const bound = (await queryTarget(() => step.target()?.tourOpen(props.Z_INDEX))) ?? {}
      currentBound.value = bound
    }
  })
})

watch(
  () => props.open,
  async (show) => {
    overlay.value = show

    if (!show) {
      index.value = -1
      return
    }

    handleNextStep(0)
  },
  { immediate: true }
)

function queryTarget(get: () => Promise<UniApp.NodeInfo>) {
  return new Promise((resolve) => {
    let count = 3
    async function loop() {
      count--

      if (count <= 0) {
        resolve({})
        return
      }

      const res = await get()

      if (res) {
        resolve(res)
        return
      }

      setTimeout(() => {
        loop()
      }, 100)
    }

    loop()
  })
}


function handleTouchMove() {
  return false
}
</script>

<style lang="scss" scoped>
  /* pad 支持的最小宽度 */
  $pad-width: 580px;

  /* figma 像素除以 2,并转为 rpx 单位 */
  @function rem($px) {
    @return $px * 2 * 1rpx;
  }

.tourStyle {
}

.tour_mask {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 9000; // 高于 overly
  width: 100%;
  height: 756px;
}
</style>

overlay.vue

html 复制代码
<template>
  <view class="overlayStyle">
    <view
      v-if="props.show"
      class="mask"
      
      :style="{ opacity: props.show ? props.opacity : 0, zIndex: props.zIndexMask ? props.zIndexMask : 8021}"
      @touchmove.stop.prevent="handleTouchMove"
      @click.stop="handleMaskClick"
    />

    <view v-if="props.show" class="overlay_content" @touchmove.stop.prevent="handleTouchMove">
      <slot />
    </view>
  </view>
</template>

<script lang="ts" setup>

interface IOverlayProps {
  /** 是否显示 */
  show: boolean
  /**
   * 遮罩透明度,0 ~ 1
   * @default 0.6
   */
  opacity?: number
  /** 点击遮罩 */
  onClick?(): void
  /** 在遮罩上滑动 */
  onTouchMove?(): void
  zIndexMask?: any
}

const props = withDefaults(defineProps<IOverlayProps>(), {
  show: false,
  opacity: 0.6,
  onTouchMove: () => {}
})

function handleMaskClick() {
  props.onClick?.()
}

function handleTouchMove() {
  props.onTouchMove()
  return false
}
</script>

<style lang="scss" scoped>
 /* pad 支持的最小宽度 */
 $pad-width: 580px;

 /* figma 像素除以 2,并转为 rpx 单位 */
 @function rem($px) {
    @return $px * 2 * 1rpx;
 }

.mask {
  position: fixed;
  left: 0;
  top: 0;
  width: 100vw;
  height: 100vh;
  z-index: 8021;
  opacity: 1;
  transition: opacity 200ms linear;
  background: #000;
}

.overlay_content {
  position: relative;
  z-index: 8022;
  // pointer-events: auto;
}
</style>

useSuperLock.ts

javascript 复制代码
import { ref } from '@vue/composition-api'

/**
 * 超级锁钩子。未运行完毕锁。500毫秒运行一次锁。运行成功500毫秒后才能运行锁。
 *
 * @param setLoading
 * @param fun
 */
export function useSuperLock<T extends (...args: any) => any>(fun: T, delay = 300) {
  const lock = ref(false)
  const lastDate = ref<Date>()

  const fn: T = (async (...args: Parameters<T>) => {
    if (lock.value) {
      return
    }

    const nowDate = new Date()
    if (lastDate.value && nowDate.getTime() - lastDate.value.getTime() <= delay) {
      return
    }

    lastDate.value = nowDate
    lock.value = true

    let returnValue: any
    try {
      returnValue = await fun.apply(null, args)
    } catch (error) {
      lock.value = false
      throw error
    }

    setTimeout(() => {
      lock.value = false
    }, delay)

    return returnValue
  }) as T

  return [fn, lock] as const
}
相关推荐
烛阴4 小时前
代码的“病历本”:深入解读C#常见异常
前端·c#
ChinaRainbowSea4 小时前
Spring Boot3 + JDK21 的迁移 超详细步骤
java·spring boot·后端·spring
從南走到北4 小时前
JAVA海外短剧国际版源码支持H5+Android+IOS
android·java·ios
IT_陈寒4 小时前
Python 3.12 新特性实战:10个提升开发效率的隐藏技巧大揭秘
前端·人工智能·后端
CoderYanger4 小时前
动态规划算法-子数组、子串系列(数组中连续的一段):26.环绕字符串中唯一的子字符串
java·算法·leetcode·动态规划·1024程序员节
老华带你飞4 小时前
旅游|基于Java旅游信息推荐系统(源码+数据库+文档)
java·开发语言·数据库·vue.js·spring boot·后端·旅游
青云交4 小时前
Java 大视界 -- 基于 Java 的大数据可视化在企业供应链动态监控与优化中的应用
java·数据采集·大数据可视化·动态优化·企业供应链·实时预警·供应链监控
van久5 小时前
.Net Core 学习:DbContextOptions<T> vs DbContextOptions 详细解析
java·学习·.netcore
Coder_Boy_5 小时前
【物联网技术】- 基础理论-0001
java·python·物联网·iot