<!--
 * @Author: xiejiang
 * @Date: 2023-07-13 14:14:11
 * @LastEditors: zengzhe
 * @LastEditTime: 2024-12-27 14:40:25
 * @Description: 拼图人机验证、右滑拼图验证
-->
<template>
  <div class="slide-verify" :style="{ width: canvasWidth + 'px' }" onselectstart="return false;">
    <!-- 图片加载遮蔽罩 -->
    <div
      class="f-c"
      :class="{ 'img-loading': state.isLoading }"
      :style="{ height: canvasHeight + 'px' }"
      v-if="state.isLoading"
    >
      图形验证码加载中...
    </div>
    <!-- 认证成功后的文字提示 -->
    <div class="success-hint" :style="{ height: canvasHeight + 'px' }" v-if="state.verifySuccess">
      {{ state.successHint }}
    </div>
    <!--刷新按钮-->
    <div class="refresh-icon" @click="refresh" />
    <!--前端生成-->
    <template v-if="state.isFrontCheck">
      <!--验证图片-->
      <canvas ref="canvas" class="slide-canvas" :width="canvasWidth" :height="canvasHeight" />
      <!--阻塞块-->
      <canvas ref="block" class="slide-block" :width="canvasWidth" :height="canvasHeight" />
    </template>
    <!--后端生成-->
    <template v-else>
      <!--验证图片-->
      <img ref="canvas" class="slide-canvas" :width="canvasWidth" :height="canvasHeight" />
      <!--阻塞块-->
      <img ref="block" :class="['slide-block', { 'verify-fail': state.verifyFail }]" />
    </template>
    <!-- 滑动条 -->
    <div
      class="slider"
      :class="{
        'verify-active': state.verifyActive,
        'verify-success': state.verifySuccess,
        'verify-fail': state.verifyFail
      }"
    >
      <!--滑块-->
      <div class="slider-box" :style="{ width: state.sliderBoxWidth }">
        <!-- 按钮 -->
        <div class="slider-button" id="slider-button" :style="{ left: state.sliderButtonLeft }">
          <!-- 按钮图标 -->
          <div class="slider-button-icon" />
        </div>
      </div>
      <!--滑动条提示文字-->
      <span class="slider-hint">{{ sliderHint }}</span>
    </div>
  </div>
</template>

<script setup lang="ts">
import { Props, State, EmitProps } from '@/components/SlideVerify/index'
// import { ResultData } from '@/api/interface/index'
import { getSliderCaptcha } from '@/api/modules/login/index'

defineOptions({
  name: 'sliderVerify'
})

function sum(x: number, y: number) {
  return x + y
}

function square(x: number) {
  return x * x
}

const emit = defineEmits<EmitProps>()

const block = ref()

const canvas = ref()

const props = withDefaults(defineProps<Props>(), {
  // 阻塞块长度
  blockLength: 42,
  // 阻塞块弧度
  blockRadius: 10,
  // 画布宽度
  canvasWidth: 320,
  // 画布高度
  canvasHeight: 155,
  // 滑块操作提示
  sliderHint: '向右滑动',
  // 可允许的误差范围小；为1时，则表示滑块要与凹槽完全重叠，才能验证成功。默认值为5，若为 -1 则不进行机器判断
  accuracy: 3,
  // 图片资源数组
  imageList: []
})
const state = reactive<State>({
  // 前端校验
  isFrontCheck: false,
  // 校验进行状态
  verifyActive: false,
  // 校验成功状态
  verifySuccess: false,
  // 校验失败状态
  verifyFail: false,
  // 阻塞块对象
  blockObj: null,
  // 图片画布对象
  canvasCtx: null,
  // 阻塞块画布对象
  blockCtx: null,
  // 阻塞块宽度
  blockWidth: props.blockLength * 2,
  // 阻塞块的横轴坐标
  blockX: 0,
  // 阻塞块的纵轴坐标
  blockY: 0,
  // 图片对象
  image: document.createElement('img'),
  // 移动的X轴坐标
  originX: 0,
  // 移动的Y轴做坐标
  originY: 0,
  // 拖动距离数组
  dragDistanceList: [],
  // 滑块箱拖动宽度
  sliderBoxWidth: 0,
  // 滑块按钮距离左侧起点位置
  sliderButtonLeft: 0,
  // 鼠标按下状态
  isMouseDown: false,
  // 图片加载提示，防止图片没加载完就开始验证
  isLoading: true,
  // 时间戳，计算滑动时长
  timestamp: 0,
  // 成功提示
  successHint: '',
  // 随机字符串
  nonceStr: ''
})
onMounted(() => {
  init()
})
/* 初始化*/
function init() {
  initDom()
  bindEvents()
}
/* 初始化DOM对象*/
function initDom() {
  state.blockObj = block.value
  if (state.isFrontCheck) {
    state.canvasCtx = canvas.value.getContext('2d')
    state.blockCtx = state.blockObj.getContext('2d')
    initImage()
  } else {
    getCaptcha()
  }
}
/* 后台获取验证码*/
function getCaptcha() {
  //取后端默认值
  state.isLoading = true
  getSliderCaptcha()
    .then(({ data }) => {
      state.nonceStr = data.nonceStr
      block.value.src = data.blockSrc
      block.value.style.top = data.blockY + 'px'
      canvas.value.src = data.canvasSrc
    })
    .finally(() => {
      state.isLoading = false
    })
}
/* 前端获取验证码*/
function initImage() {
  const image = createImage(() => {
    drawBlock()
    let { canvasWidth, canvasHeight, blockRadius } = props
    let { blockX, blockY, blockWidth } = state
    state.canvasCtx.drawImage(image, 0, 0, canvasWidth, canvasHeight)
    state.blockCtx.drawImage(image, 0, 0, canvasWidth, canvasHeight)
    // 将抠图防止最左边位置
    let yAxle = blockY - blockRadius * 2
    let ImageData = state.blockCtx.getImageData(blockX, yAxle, blockWidth, blockWidth)
    state.blockObj.width = blockWidth
    state.blockCtx.putImageData(ImageData, 0, yAxle)
    // 图片加载完关闭遮蔽罩
    state.isLoading = false
    // 前端校验设置特殊值
    state.nonceStr = 'loyer'
  })
  state.image = image
}
/* 创建image对象*/
function createImage(onload: any) {
  const image = document.createElement('img')
  image.crossOrigin = 'Anonymous'
  image.onload = onload
  image.onerror = () => {
    image.src = ''
  }
  image.src = getImageSrc()
  return image
}
/* 获取imgSrc*/
function getImageSrc() {
  const len = props.imageList.length
  return len > 0
    ? props.imageList[getNonceByRange(0, len)]
    : `https://loyer.wang/view/ftp/wallpaper/${getNonceByRange(1, 1000)}.jpg`
}
/* 根据指定范围获取随机数*/
function getNonceByRange(start: number, end: number) {
  return Math.round(Math.random() * (end - start) + start)
}
/* 绘制阻塞块*/
function drawBlock() {
  state.blockX = getNonceByRange(state.blockWidth + 10, props.canvasWidth - (state.blockWidth + 10))
  state.blockY = getNonceByRange(10 + props.blockRadius * 2, props.canvasHeight - (state.blockWidth + 10))
  draw(state.canvasCtx, 'fill')
  draw(state.blockCtx, 'clip')
}
/* 绘制事件*/
function draw(ctx: any, operation: string) {
  const PI = Math.PI
  let { blockX: x, blockY: y } = state
  let { blockLength: l, blockRadius: 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.9)'
  ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)'
  ctx.stroke()
  ctx[operation]()
  ctx.globalCompositeOperation = 'destination-over'
}
/* 事件绑定*/
function bindEvents() {
  const sliderButton = document.getElementById('slider-button') as HTMLElement
  // 监听鼠标按下事件

  sliderButton.addEventListener('mousedown', event => {
    startEvent(event.clientX, event.clientY)
  })
  // 监听鼠标移动事件
  document.addEventListener('mousemove', event => {
    moveEvent(event.clientX, event.clientY)
  })
  // 监听鼠标离开事件
  document.addEventListener('mouseup', event => {
    endEvent(event.clientX)
  })
  // 监听触摸开始事件
  sliderButton.addEventListener('touchstart', event => {
    startEvent(event.changedTouches[0].pageX, event.changedTouches[0].pageY)
  })
  // 监听触摸滑动事件
  document.addEventListener('touchmove', event => {
    moveEvent(event.changedTouches[0].pageX, event.changedTouches[0].pageY)
  })
  // 监听触摸离开事件
  document.addEventListener('touchend', event => {
    endEvent(event.changedTouches[0].pageX)
  })
}
/* 校验图片是否存在*/
function checkImgSrc() {
  if (state.isFrontCheck) {
    return true
  }
  return !!canvas.value.src
}
/* 滑动开始事件*/
function startEvent(originX: number, originY: number) {
  if (!checkImgSrc() || state.isLoading || state.verifySuccess) {
    return
  }
  state.originX = originX
  state.originY = originY
  state.isMouseDown = true
  state.timestamp = +new Date()
}
/* 滑动事件*/
function moveEvent(originX: number, originY: number) {
  if (!state.isMouseDown) {
    return false
  }
  const moveX = originX - state.originX
  const moveY = originY - state.originY
  if (moveX < 0 || moveX + 40 >= props.canvasWidth) {
    return false
  }
  state.sliderButtonLeft = moveX + 'px'
  let blockLeft = ((props.canvasWidth - 40 - 20) / (props.canvasWidth - 40)) * moveX
  state.blockObj.style.left = blockLeft + 'px'
  state.verifyActive = true
  state.sliderBoxWidth = moveX + 'px'
  state.dragDistanceList.push(moveY)
}
/* 滑动结束事件*/
function endEvent(originX: number) {
  if (!state.isMouseDown) {
    return false
  }
  state.isMouseDown = false
  if (originX === state.originX) {
    return false
  }
  // 开始校验
  state.isLoading = true
  // 校验结束
  state.verifyActive = false
  // 滑动时长
  state.timestamp = +new Date() - state.timestamp
  // 移动距离
  const moveLength = parseInt(state.blockObj.style.left)
  // 限制操作时长10S，超出判断失败
  if (state.timestamp > 10000) {
    verifyFailEvent()
  }
  // 人为操作判定
  else if (!turingTest()) {
    state.verifyFail = true
    emit('again')
  }
  // 是否前端校验
  else if (state.isFrontCheck) {
    const accuracy = props.accuracy <= 1 ? 1 : props.accuracy > 10 ? 10 : props.accuracy // 容错精度值
    const spliced = Math.abs(moveLength - state.blockX) <= accuracy // 判断是否重合
    if (!spliced) {
      verifyFailEvent()
    } else {
      // 设置特殊值，后台特殊处理，直接验证通过
      emit('success', { nonceStr: state.nonceStr, value: moveLength })
    }
  } else {
    emit('success', { nonceStr: state.nonceStr, blockY: moveLength })
  }
}
/* 图灵测试*/
function turingTest() {
  const arr = state.dragDistanceList // 拖动距离数组
  const average = arr.reduce(sum) / arr.length // 平均值
  const deviations = arr.map(x => x - average) // 偏离值
  const stdDev = Math.sqrt(deviations.map(square).reduce(sum) / arr.length) // 标准偏差
  return average !== stdDev // 判断是否人为操作
}
/* 校验成功*/
// function verifySuccessEvent() {
//   state.isLoading = false
//   state.verifySuccess = true
//   const elapsedTime = (state.timestamp / 1000).toFixed(1)
//   if (elapsedTime < 1) {
//     state.successHint = `仅仅${elapsedTime}S，你的速度快如闪电`
//   } else if (elapsedTime < 2) {
//     state.successHint = `只用了${elapsedTime}S，这速度简直完美`
//   } else {
//     state.successHint = `耗时${elapsedTime}S，争取下次再快一点`
//   }
// }
/* 校验失败*/
function verifyFailEvent() {
  state.verifyFail = true
  // console.log(msg, '效验')
  // $emit('fail', msg);
  refresh()
}
/* 刷新图片验证码*/
function refresh() {
  // 延迟class的删除，等待动画结束
  setTimeout(() => {
    state.verifyFail = false
  }, 500)
  state.isLoading = true
  state.verifyActive = false
  state.verifySuccess = false
  state.blockObj.style.left = 0
  state.sliderBoxWidth = 0
  state.sliderButtonLeft = 0
  if (state.isFrontCheck) {
    // 刷新画布
    let { canvasWidth, canvasHeight } = props
    state.canvasCtx.clearRect(0, 0, canvasWidth, canvasHeight)
    state.blockCtx.clearRect(0, 0, canvasWidth, canvasHeight)
    state.blockObj.width = canvasWidth
    // 刷新图片
    state.image.src = getImageSrc()
  } else {
    getCaptcha()
  }
}
//暴露函数 让父组件可以调用此方法
defineExpose({
  refresh
})
</script>

<style scoped>
.slide-verify {
  position: relative;
}

/* 图片加载样式 */
.img-loading {
  position: absolute;
  inset: 0;
  z-index: 999;
  color: #fff;
  background-color: #737c8e;

  /* background-image: url(''); */
  background-repeat: no-repeat;
  background-position: center center;
  background-size: 100px;
  border-radius: 5px;
  animation: loading 1.5s infinite;
}

@keyframes loading {
  0% {
    opacity: 0.7;
  }

  100% {
    opacity: 9;
  }
}

/* 认证成功后的文字提示 */
.success-hint {
  position: absolute;
  top: 0;
  right: 0;
  left: 0;
  z-index: 999;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: large;
  color: #2cd000;
  background: rgb(255 255 255 / 80%);
}

/* 刷新按钮 */
.refresh-icon {
  position: absolute;
  top: 0;
  right: 0;
  width: 35px;
  height: 35px;
  cursor: pointer;

  /* background: url('') 0 -432px; */
  background-size: 35px 470px;
}

/* 验证图片 */
.slide-canvas {
  border-radius: 5px;
}

/* 阻塞块 */
.slide-block {
  position: absolute;
  top: 0;
  left: 0;
}

/* 校验失败时的阻塞块样式 */
.slide-block.verify-fail {
  transition: left 0.5s linear;
}

/* 滑动条 */
.slider {
  position: relative;
  width: 100%;
  height: 40px;
  margin-top: 15px;
  line-height: 40px;
  color: #45494c;
  text-align: center;
  background: #f7f9fa;
  border: 1px solid #e4e7eb;
  border-radius: 5px;
}

/* 滑动盒子 */
.slider-box {
  position: absolute;
  top: 0;
  left: 0;
  height: 40px;
  background: #d1e9fe;
  border: 0 solid #1991fa;
  border-radius: 5px;
}

/* 滑动按钮 */
.slider-button {
  position: absolute;
  top: 0;
  left: 0;
  width: 40px;
  height: 40px;
  cursor: pointer;
  background: #fff;
  border-radius: 5px;
  box-shadow: 0 0 3px rgb(0 0 0 / 30%);
  transition: background 0.2s linear;
}

/* 鼠标悬浮时的按钮样式 */
.slider-button:hover {
  background: #1991fa;
}

/* 鼠标悬浮时的按钮图标样式 */
.slider-button:hover .slider-button-icon {
  background-position: 0 -13px;
}

/* 滑动按钮图标 */
.slider-button-icon {
  position: absolute;
  top: 15px;
  left: 13px;
  width: 15px;
  height: 13px;

  /* background: url('') 0 -26px; */
  background-size: 35px 470px;
}

/* 校验时的按钮样式 */
.verify-active .slider-button {
  top: -1px;
  height: 38px;
  border: 1px solid #1991fa;
}

/* 校验时的滑动箱样式 */
.verify-active .slider-box {
  height: 38px;
  border-width: 1px;
}

/* 校验成功时的滑动箱样式 */
.verify-success .slider-box {
  height: 38px;
  background-color: #d2f4ef;
  border: 1px solid #52ccba;
}

/* 校验成功时的按钮样式 */
.verify-success .slider-button {
  top: -1px;
  height: 38px;
  background-color: #52ccba !important;
  border: 1px solid #52ccba;
}

/* 校验成功时的按钮图标样式 */
.verify-success .slider-button-icon {
  background-position: 0 0 !important;
}

/* 校验失败时的滑动箱样式 */
.verify-fail .slider-box {
  height: 38px;
  background-color: #fce1e1;
  border: 1px solid #f57a7a;
  transition: width 0.5s linear;
}

/* 校验失败时的按钮样式 */
.verify-fail .slider-button {
  top: -1px;
  height: 38px;
  background-color: #f57a7a !important;
  border: 1px solid #f57a7a;
  transition: left 0.5s linear;
}

/* 校验失败时的按钮图标样式 */
.verify-fail .slider-button-icon {
  top: 14px;
  background-position: 0 -82px !important;
}

/* 校验状态下的提示文字隐藏 */
.verify-active .slider-hint,
.verify-success .slider-hint,
.verify-fail .slider-hint {
  display: none;
}
</style>
