VUE3--实现图片滑动验证功能(vite2+vue3.2)

概要

滑动验证是多种登录验证方式中相对操作比较便捷的一种方式,本文基于vite2+vue3.2环境实现了滑动验证码,使用了script setup这种语法糖方式开发组件功能,这样使得Composition API使用更加便捷高效,得心应手。

初始化项目

使用npm执行以下命令:

npm init vite@latest

按照操作提示进行操作即可

配置vite.config.js

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
      'public': path.resolve(__dirname, './public')
    }
  }
})

assets目录下新建images目录,导入600X400的背景图和图标雪碧图

滑动验证组件代码

template部分代码

<template>
  <div
    class="sv-box"
    :style="{width: w + 'px'}"
    onselectstart="return false">
    <!-- 加载背景图片遮罩层 -->
    <div :class="{'bg-mask': state.loadFlag}"></div>
    <div
      v-show="show"
      class="sv-refresh"
      @click="refresh">
    </div>
    <canvas
      :width="w"
      :height="h"
      ref="canvas">
    </canvas>
    <canvas
      :width="w"
      :height="h"
      ref="block"
      class="canvas-block">
    </canvas>
    <!-- 滑动条容器 -->
    <div
      class="slider"
      :class="{
        'active': state.sliderActive,
        'success': state.sliderSuccess,
        'fail': state.sliderFail
      }">
      <div
        class="slider--mask"
        :style="{width: state.sliderMaskWidth}">
        <!-- 滑动块 -->
        <div
          class="slider--inner"
          :style="{left: state.sliderLeft}"
          @mousedown="sliderDown"
          @touchstart="touchStartEvt"
          @touchmove="touchMoveEvt"
          @touchend="touchEndEvt">
          <div class="slider--icon"></div>
        </div>
      </div>
      <span class="slider--text">{{ text }}</span>
    </div>
  </div>
</template>

script部分代码

<script setup>
import bg1 from '@/assets/images/bg1.png'
import bg2 from '@/assets/images/bg2.png'
import bg3 from '@/assets/images/bg3.png'
import bg4 from '@/assets/images/bg4.png'
import bg5 from '@/assets/images/bg5.png'
import { ref, onMounted, reactive } from 'vue'
const props = defineProps({
  l: {
    type: Number,
    default: 42
  },
  r: {
    type: Number,
    default: 10
  },
  w: {
    type: Number,
    default: 310
  },
  h: {
    type: Number,
    default: 155
  },
  text: {
    type: String,
    default: '向右侧滑动'
  },
  accuracy: {
    type: Number,
    default: 5
  },
  show: {
    type: Boolean,
    default: true
  },
  bgList: {
    type: Array,
    default: () => [bg1, bg2, bg3, bg4, bg5]
  }
})

const PI = Math.PI
const sum = (x, y) => {
  return x + y
}
const square = (x) => {
  return x * x
}

const state = reactive({
  sliderActive: false,
  sliderSuccess: false,
  sliderFail: false,
  canvasCtx: null,
  blockCtx: null,
  block: null,
  blockX: undefined,
  blockY: undefined,
  L: props.l + props.r * 2 + 3,
  img: undefined,
  originX: undefined,
  originY: undefined,
  isMouseDown: false,
  trail: [],
  sliderLeft: '0px',
  sliderMaskWidth: 0,
  success: false,
  loadFlag: false,
  timestamp: null
})

const block = ref(null)
const canvas = ref(null)

const emit = defineEmits(['refresh', 'success', 'fail', 'again', 'fulfilled'])

const init = () => {
  initDom()
  initImg()
  bindEvt()
}

const initDom = () => {
  state.block = block.value
  state.canvasCtx = canvas.value.getContext('2d')
  state.blockCtx = state.block.getContext('2d')
}

const initImg = () => {
  const img = createImg(() => {
    state.loadFlag = false
    drawBlock()
    state.canvasCtx.drawImage(img, 0, 0, props.w, props.h)
    state.blockCtx.drawImage(img, 0, 0, props.w, props.h)

    const _y = state.blockY - props.r * 2 -1
    const imageDate = state.blockCtx.getImageData(state.blockX, _y, state.L, state.L)
    state.block.width = state.L
    state.blockCtx.putImageData(imageDate, 0, _y)
  })
  state.img = img
}

const bindEvt = () => {
  const moveEvt = (e) => {
    if (!state.isMouseDown) return
    const moveX = e.clientX - state.originX
    const moveY = e.clientY - state.originY
    if (moveX < 0 || moveX + 40 >= props.w) return
    state.sliderLeft = moveX + 'px'
    const blockLeft = (props.w - 40 - 20) / (props.w - 40) * moveX
    state.block.style.left = blockLeft + 'px'

    state.sliderActive = true // add active
    state.sliderMaskWidth = moveX + 'px'
    state.trail.push(moveY)
  }
  const upEvt = (e) => {
    if (!state.isMouseDown) return
    state.isMouseDown = false
    if (e.clientX === state.originX) return
    state.sliderActive = false // remove active
    state.timestamp = new Date() - state.timestamp

    const {
      isPass,
      isRobot
    } = verify()
    if (isPass) {
      if(props.accuracy === -1) {
        state.sliderSuccess = true
        state.success = true
        emit('success', state.timestamp)
        return
      }
      if (isRobot) {
        // succ
        state.sliderSuccess = true
        state.success = true
        emit('success', state.timestamp)
      } else {
        state.sliderFail = true
        emit('again')
      }
    } else {
      state.sliderFail = true
      emit('fail')
      setTimeout(() => {
        reset()
      }, 1000)
    }
  }
  document.addEventListener('mousemove', moveEvt)
  document.addEventListener('mouseup', upEvt)
}

const sliderDown = (e) => {
  if (state.success) return
  state.originX = e.clientX
  state.originY = e.clientY
  state.isMouseDown = true
  state.timestamp = new Date()
}

const touchStartEvt = (e) => {
  if (state.success) return
  state.originX = e.changedTouches[0].pageX
  state.originY = e.changedTouches[0].pageY
  state.isMouseDown = true
  state.timestamp = new Date()
}
const touchMoveEvt = (e) => {
  if (!state.isMouseDown) return
  const moveX = e.changedTouches[0].pageX - state.originX
  const moveY = e.changedTouches[0].pageY - state.originY
  if (moveX < 0 || moveX + 38 >= state.w) return
  state.sliderLeft = moveX + 'px'
  let blockLeft = (props.w - 40 - 20) / (props.w - 40) * moveX
  state.block.style.left = blockLeft + 'px'

  state.sliderActive = true
  state.sliderMaskWidth = moveX + 'px'
  state.trail.push(moveY)
}
const touchEndEvt = (e) => {
  if (!state.isMouseDown) return
  state.isMouseDown = false
  if (e.changedTouches[0].pageX === this.originX) return
  state.sliderActive = false
  state.timestamp = new Date() - state.timestamp

  const {
    isPass,
    isRobot
  } = verify()
  if (isPass) {
    if(props.accuracy === -1) {
      state.sliderSuccess = true
      state.success = true
      emit('success', state.timestamp)
      return
    }
    if (isRobot) {
      // succ
      state.sliderSuccess = true
      state.success = true
      emit('success', state.timestamp)
    } else {
      state.sliderFail = true
      emit('again')
    }
  } else {
    state.sliderFail = true
    emit('fail')
    setTimeout(() => {
      reset()
    }, 1000)
  }
}

const reset = () => {
  state.success = false
  state.sliderFail = false
  state.sliderSuccess = false
  state.sliderFail = false
  state.sliderLeft = 0
  state.block.style.left = 0
  state.sliderMaskWidth = 0
  // canvas
  const {
    w,
    h
  } = props
  state.canvasCtx.clearRect(0, 0, w, h)
  state.blockCtx.clearRect(0, 0, w, h)
  state.block.width = w

  // generate img
  state.img.src = getRandomBg()
  emit('fulfilled')
}

const refresh = () => {
  reset()
  emit('refresh')
}

const drawBlock = () => {
  state.blockX = getRandomNumberByRange(state.L + 10, props.w - (state.L + 10))
  state.blockY = getRandomNumberByRange(props.r + 10, props.h - (state.L + 10))
  draw(state.canvasCtx, state.blockX, state.blockY, 'fill')
  draw(state.blockCtx, state.blockX, state.blockY, 'clip')
}

const draw = (ctx, x, y, type) => {
  const { l, r } = props
  ctx.beginPath()
  ctx.moveTo(x, y)
  ctx.arc(x + l / 2, y - r + 2, r, 0.72 * PI, 2.26 * PI)
  ctx.lineTo(x + l, y)
  ctx.arc(x + l + r - 2, y + l / 2, r, 1.21 * PI, 2.78 * PI)
  ctx.lineTo(x + l, y + l)
  ctx.lineTo(x, y + l)
  ctx.arc(x + r - 2, y + l / 2, r + 0.4, 2.76 * PI, 1.24 * PI, true)
  ctx.lineTo(x, y)
  ctx.lineWidth = 2
  ctx.fillStyle = 'rgba(255, 255, 255, 0.7)'
  ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)'
  ctx.stroke()
  ctx[type]()
  ctx.globalCompositeOperation = 'destination-over'
}

const createImg = (onload) => {
  const img = document.createElement('img')
  img.crossOrigin = 'Anonymous'
  img.onload = onload
  img.error = () => {
    console.error('Background image failed to load')
  }
  img.src = getRandomBg()

  return img
}

const getRandomBg =  () => {
  const len = props.bgList.length
  return props.bgList[getRandomNumberByRange(0, len)]
}

const getRandomNumberByRange = (start, end) => {
  return Math.round(Math.random() * (end - start) + start)
}

const verify = () => {
  const arr = state.trail // 拖拽时y轴的轨迹坐标
  const average = arr.reduce(sum) / arr.length // 纵坐标的平均值
  const deviations = arr.map(y => y - average) // 纵坐标与其平均值的插值数组
  const stddev = Math.sqrt(deviations.map(square).reduce(sum) / arr.length) // 标准差
  const left = parseInt(state.block.style.left)
  const accuracy = props.accuracy <= 1 ? 1 : props.accuracy > 10 ? 10 : props.accuracy

  return {
    isPass: Math.abs(left - state.blockX) <= accuracy,
    isRobot: average !== stddev
  }
}

onMounted(() => {
  init()
})

</script>

style部分代码

<style lang="scss" scoped>
.sv-box {
  position: relative;
  .bg-mask {
    position: absolute;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    background: rgba(255, 255, 255, 0.9);
    z-index: 999;
    animation: loading 1.5s infinite;
  }

  @keyframes loading {
    0% {
      opacity: 0.7;
    }
    100% {
      opacity: 1;
    }
  }
  .canvas-block {
    position: absolute;
    left: 0;
    top: 0;
  }
  .sv-refresh {
    position: absolute;
    right: 0;
    top: 0;
    width: 34px;
    height: 34px;
    cursor: pointer;
    background: url("@/assets/images/icons-sprite.png");
    background-position: 34px 35px;
    z-index: 999;
  }

  .slider {
    position: absolute;
    text-align: center;
    width: 100%;
    height: 40px;
    line-height: 40px;
    margin-top: 15px;
    color: #45494c;
    border: 1px solid #e4e7eb;
    background: #f7f9fa;
    .slider--mask {
      position: absolute;
      left: -1px;
      top: -1px;
      height: 40px;
      border: 1px solid transparent;
      background: transparent;
      .slider--inner {
        position: absolute;
        top: 0px;
        left: 0px;
        width: 40px;
        height: 40px;
        background: #fff;
        cursor: pointer;
        transition: background .2s linear;
        .slider--icon {
          position: absolute;
          top: 15px;
          left: 13px;
          width: 14px;
          height: 12px;
          background: url("@/assets/images/icons-sprite.png");
          background-position: 34px 445px;
        }
        &:hover {
          background: #1991fa;
          .slider--icon {
            background-position: 34px 458px;
          }
        }
      }
    }
  }

  .active .slider--mask {
    border: 1px solid #1991fa;
  }

  .active .slider--inner {
    top: -1px !important;
    border: 1px solid #1991fa;
    background-color: #1991fa !important;
  }

  .success .slider--mask {
    border: 1px solid #52ccba;
  }

  .success .slider--inner {
    top: -1px !important;
    border: 1px solid #52ccba;
    background-color: #52ccba !important;
  }

  .success .slider--icon {
    background-position: 0 0 !important;
  }

  .fail .slider--mask {
    border: 1px solid #f57a7a;
    background-color: #fce1e1;
  }

  .fail .slider--inner {
    top: -1px !important;
    border: 1px solid #f57a7a;
    background-color: #f57a7a !important;
  }

  .active .slider--text,
  .success .slider--text,
  .fail .slider--text {
    display: none;
  }
}
</style>

效果图

posted @ 2021-08-19 20:58  Elwin0204  阅读(2139)  评论(2编辑  收藏  举报