LazySlideCaptcha (基于SkiaSharp)滑动验证码 及前端组件vue3的实现

该文前面部分来自项目开源介绍,官方前端组件基于vue2实现,文后提供vue3实现;

另外:项目默认验证方法“DefaultCaptcha.Validate”验证完就清除缓存导致无法在要调用的后端接口完成二次验证,文后vue3实现处也提供了不清除缓存的验证方法以解决无法二次验证的问题

LazySlideCaptcha项目介绍与使用说明

源自:https://gitee.com/pojianbing/lazy-slide-captcha

介绍

LazySlideCaptcha是基于.Net Standard 2.1的滑动验证码模块。项目同时提供一个基于vue2的演示前端组件背景图裁剪工具。从2.0.0起绘图模块由ImageSharp调整为SkiaSharp。 【码云地址】|【Github地址】

图形验证码请移步lazy-captcha

在线体验点这里

输入图片说明

快速开始
  1. 安装
Install-Package Lazy.SlideCaptcha.Core
dotnet add package Lazy.SlideCaptcha.Core
  1. 注册并配置服务
builder.Services.AddDistributedMemoryCache();
builder.Services.AddSlideCaptcha(builder.Configuration);

// 如果使用redis分布式缓存
//builder.Services.AddStackExchangeRedisCache(options =>
//{
//    options.Configuration = builder.Configuration.GetConnectionString("RedisCache");
//    options.InstanceName = "captcha:";
//});
"SlideCaptcha": {
    "Backgrounds": [
      {
        "Type": "file",
        "Data": "wwwroot/images/background/1.jpg"
      },
      {
        "Type": "file",
        "Data": "wwwroot/images/background/2.jpg"
      }
    ]
  }

背景图片要求尺寸要求为 552 X 344 , 快速开始可在 Demo 项目 wwwroot/images/background 下挑选。(仅用作演示,生产请自行制作。)也可以通过裁剪工具制作,非常简单,上传图片,拖动范围后保存自动生成 552 X 344 图片。

  1. 接口定义
[Route("api/[controller]")]
[ApiController]
public class CaptchaController : ControllerBase
{
    private readonly ICaptcha _captcha;

    public CaptchaController(ICaptcha captcha)
    {
        _captcha = captcha;
    }

    /// <summary>
    /// id
    /// </summary>
    /// <param name="id"></param>
    /// <returns></returns>
    [Route("gen")]
    [HttpGet]
    public CaptchaData Generate()
    {
        return _captcha.Generate();
    }

    /// <summary>
    /// id
    /// </summary>
    /// <param name="id"></param>
    /// <returns></returns>
    [Route("check")]
    [HttpPost]
    public ValidateResult Validate([FromQuery]string id, SlideTrack track)
    {
        return _captcha.Validate(id, track);
    }
}

至此后端Api服务已搭建完成。

  1. 前端
    前端提供演示组件lazy-slide-captcha,可通过npm安装。Demo项目为了演示方便直接采用script直接引入方式。
@{
    ViewData["Title"] = "滑动验证码";
}

<link rel="stylesheet" href="~/lib/lazy-slide-captcha/dist/lazy-slide-captcha.css" asp-append-version="true" />

<style>
    #app {
        display: flex;
        align-items: center;
        justify-content: center;
    }

    .panel {
        padding: 20px;
        box-shadow: inherit;
        border-radius: 6px;
        box-shadow: 0 0 4px 0 #999999;
        margin-top: 100px;
    }
</style>

<div id="app">
    <div class="panel">
        <lazy-slide-captcha ref="captcha" :width="width" :height="height" :show-refresh="true" :fail-tip="failTip" :success-tip="successTip" @@finish="handleFinish" @@refresh="generate"></lazy-slide-captcha>
    </div>
</div>


@section Scripts{
    <script src="~/lib/vue/vue.min.js"></script>
    <script src="~/lib/vue/axios.min.js"></script>
    <script src="~/lib/lazy-slide-captcha/dist/lazy-slide-captcha.umd.js"></script>

    <script>
        var app = new Vue({
             el: '#app',
             data(){
                return {
                    requestId: undefined,
                    failTip: '',
                    successTip: '',
                    // width,height保持与552 * 344同比例即可
                    width: 340,
                    height: 212
                }
             },
             mounted(){
                 this.generate()
             },
             methods:{
                 generate(){
                     // 改变内部状态,标识生成请求开始
                     this.$refs.captcha.startRequestGenerate()

                     axios.get('/api/captcha/gen')
                       .then((response) => {
                           this.requestId = response.data.id
                           // 改变内部状态,标识生成请求结束,同时设定background,slider图像
                           this.$refs.captcha.endRequestGenerate(response.data.backgroundImage, response.data.sliderImage)
                       })
                       .catch((error) => {
                           console.log(error);
                           // 标识生成请求结束
                           this.$refs.captcha.endRequestGenerate(null, null)
                       });
                 },
                 handleFinish(data){
                     // 改变内部状态,标识验证请求开始
                     this.$refs.captcha.startRequestVerify()

                     axios.post(`/api/captcha/check?id=${this.requestId}`, data)
                       .then((response) => {
                           let success = response.data.result === 0
                           // 验证失败时显示信息
                           this.failTip = response.data.result == 1 ? '验证未通过,拖动滑块将悬浮图像正确合并' : '验证超时, 请重新操作'
                           // 验证通过时显示信息
                           this.successTip = '验证通过,超过80%用户'
                           // 改变内部状态,标识验证请求结束,同时设定是否成功状态
                           this.$refs.captcha.endRequestVerify(success)

                           if(!success){
                                setTimeout(() => {
                                    this.generate()
                                }, 1000)
                           }
                       })
                       .catch((error) => {
                         console.log(error);
                         this.failTip = '服务异常,请稍后重试'
                         // 标识验证请求结束
                         this.$refs.captcha.endRequestVerify(false)
                       });
                 }
             }
        });
    </script>
}
配置说明

支持配置文件和代码配置,同时配置则代码配置覆盖配置文件。

  • 配置文件
"SlideCaptcha": {
    "ExpirySeconds": 60, // 缓存过期时长
    "StoreageKeyPrefix": "", // 缓存前缀
    "Tolerant": 0.02, // 容错值(校验时用,缺口位置与实际滑动位置匹配容错范围)
    "Backgrounds": [ // 背景图配置
      {
        "Type": "file",
        "Data": "wwwroot/images/background/1.jpg"
      }
    ],
    // Templates不配置,则使用默认模板
    "Templates": [
      {
        "Slider": {
          "Type": "file",
          "Data": "wwwroot/images/template/1/slider.png"
        },
        "Hole": {
          "Type": "file",
          "Data": "wwwroot/images/template/1/hole.png"
        }
      }
    ]
  }
  • 代码配置
builder.Services.AddSlideCaptcha(builder.Configuration, options =>
{
    options.Tolerant = 0.02f;
    options.StoreageKeyPrefix = "slider-captcha";

    options.Backgrounds.Add(new Resource(FileResourceHandler.TYPE, @"wwwroot/images/background/1.jpg"));
    options.Templates.Add
    (
        TemplatePair.Create
        (
            new Resource(FileResourceHandler.TYPE, @"wwwroot/images/template/1/slider.png"),
            new Resource(FileResourceHandler.TYPE, @"wwwroot/images/template/1/hole.png")
        )
    );
});
扩展
  1. Template自定义
    Template 是指用于生成凹槽和拖块的图片,可通过Templates配置节点设置设置自定义Template。 默认五个 Template (不要配置,已经包含在类库内部)如下:
sliderholesliderhole

禁用默认 _Template_调用DisableDefaultTemplates即可:

builder.Services.AddSlideCaptcha(builder.Configuration)
    .DisableDefaultTemplates();
  1. Validator自定义 类库提供 SimpleValidator , BasicValidator 两个实现。
    SimpleValidator 仅位置验证,BasicValidator除位置验证外,同时对轨迹做验证。BasicValidator由于算法的原因,容易误判,因此类库默认用SimpleValidator_ 做为默认 Validator 。
    自定义 Validator 继承 BaseValidator , BaseValidator 提供了基本的位置验证。

举一个栗子:

public class CustomValidator: BaseValidator
{
    public override bool ValidateCore(SlideTrack slideTrack, CaptchaValidateData captchaValidateData)
    {
        // BaseValidator已做了基本滑块与凹槽的对齐验证,这里做其他验证

        return true;
    }
}

替换默认的Validator

builder.Services.AddSlideCaptcha(builder.Configuration);
    .ReplaceValidator<CustomValidator>();
  1. ResourceProvider自定义
    除了通过Options配置Background和Template外,你也可以通过自定义ResourceProvider的形式提供Background和Template。
public class CustomResourceProvider : IResourceProvider
{
    public List<Resource> Backgrounds()
    {
        return Enumerable.Range(1, 10)
            .ToList()
            .Select(e => new Resource(Core.Resources.Handler.FileResourceHandler.TYPE, $"wwwroot/images/background/{e}.jpg"))
            .ToList();
    }
    
    // 这里返回自定义的Template
    public List<TemplatePair> Templates()
    {
        return new List<TemplatePair>();
    }
}

注册ResourceProvider

builder.Services.AddSlideCaptcha(builder.Configuration)
    .AddResourceProvider<CustomResourceProvider>();
  1. 自定义ResourceHandler
public class UrlResourceHandler : IResourceHandler
{
    public const string Type = "url";

    public bool CanHandle(string handlerType)
    {
        return handlerType == Type;
    }

    /// <summary>
    /// 这里仅演示,仍然从本地读取。实际需要通过Http读取
    /// </summary>
    /// <param name="resource"></param>
    /// <returns></returns>
    /// <exception cref="ArgumentNullException"></exception>
    public byte[] Handle(Resource resource)
    {
        if (resource == null) throw new ArgumentNullException(nameof(resource));
        return File.ReadAllBytes(resource.Data);
    }
}

注册ResourceHandler

builder.Services.AddSlideCaptcha(builder.Configuration)
    .AddResourceHandler<UrlResourceHandler>();
项目参考

项目参考了tianai-captchavue-drag-verify非常优秀的项目,非常感谢。

 ————————————————————————————————————————

以上来自项目开源介绍,官方前端组件基于vue2实现,以下提供前端组件的vue3实现

一、 验证码前端组件vue3实现:

由于拖动验证成功后提交到后端接口也还需要再次验证captchaId及track,而该开源项目的默认验证方法“_captcha.Validate(id, track)”会马上删除缓存,因此前端滑动验证的时候不适合使用该默认的验证方法,需另外提供保留缓存的验证方法供前端滑动时验证用。

(1). 该开源项目提供的默认验证方法代码如下(不保留缓存到调用后端接口时再次验证,不适合前端拖动验证直接使用):

//默认验证方法的实现(不保留缓存到调用后端接口时再次验证,不适合前端拖动验证直接使用)
public ValidateResult Validate(string captchaId, SlideTrack slideTrack)
{
    try
    {
        var captchaValidateData = _storage.Get<CaptchaValidateData>(captchaId);
        if (captchaValidateData == null) return ValidateResult.Timeout();
        var success = _validator.Validate(slideTrack, captchaValidateData);
        return success ? ValidateResult.Success() : ValidateResult.Fail();
    }
    finally
    {
        _storage.Remove(captchaId);
    }
}

(2). 需另外提供保留缓存的验证方法如下(根据默认验证方法简单修改即可):

//不清缓存的滑块验证方法(用于前端拖动时的验证,待后面后端接口再次完成验证再清除)
public ValidateResult CheckAsync([FromQuery] string captchaId, SlideTrack track)
{
    if (captchaId.IsNull() || track == null)
    {
        throw ResultOutput.Exception("请完成安全验证");
    }

    return _slideCaptcha.Validate(captchaId, track, false);    //最后参数false滑块验证后不清缓存(留给后续后端接口接收数据时再次验证)
}
//由于默认验证方法(源码中的DefaultCaptcha.Validate方法)会清除缓存,导致后续后提交调用后端接口时无法再次验证,因此另外提改可选不清除缓存的校验方法如下:

public class SlideCaptcha: ISlideCaptcha
{
    private IValidator _validator;
    private IStorage _storage;

    public SlideCaptcha(IValidator validator, IStorage storage)
    {
        _storage = storage;
        _validator = validator;
    }



    //提供不清缓存的参数removeIfSuccess ,false则保留,前端拖动验证时需保留缓存,待后面后端接口完成验证后再清除)
    public ValidateResult Validate(string captchaId, SlideTrack slideTrack, bool removeIfSuccess)
    {
         
        var captchaValidateData = _storage.Get<CaptchaValidateData>(captchaId);
        if (captchaValidateData == null) return ValidateResult.Timeout();
        var success = _validator.Validate(slideTrack, captchaValidateData);
        if (!success || (success && removeIfSuccess))
        {
            _storage.Remove(captchaId);
        }

        return success ? ValidateResult.Success() : ValidateResult.Fail();
        
    }
}

1. 前端滑块验证窗口组件 dialog.vue (入口文件):

//前端滑块验证窗口组件 dialog.vue (入口文件)
<template>
  <el-dialog class="my-captcha" title="请完成安全验证" draggable append-to-body width="380px" v-bind="$attrs">
    <MyCaptcha ref="myCaptchaRef" v-bind="$attrs" />
  </el-dialog>
</template>

<script lang="ts" setup name="my-captcha-dialog">
import { defineAsyncComponent, ref } from 'vue'

const MyCaptcha = defineAsyncComponent(() => import('./index.vue'))

const myCaptchaRef = ref()

//刷新滑块验证码
const refresh = () => {
  myCaptchaRef.value?.refresh()
}

defineExpose({
  refresh,
})
</script>

<style scoped lang="scss"></style>

2. 子组件 index.vue (入口文件中的MyCaptcha子组件): 

//index.vue (入口文件中的MyCaptcha子组件) 
<template>
  <SlideCaptcha
    ref="slideCaptchaRef"
    :fail-tip="state.failTip"
    :success-tip="state.successTip"
    width="100%"
    height="auto"
    @refresh="onGenerate"
    @finish="onFinish"
    v-bind="$attrs"
  />
</template>

<script lang="ts" setup name="my-captcha">
import { defineAsyncComponent, ref, reactive } from 'vue'
import { CaptchaApi } from '/@/api/admin/Captcha'

const SlideCaptcha = defineAsyncComponent(() => import('./slide-captcha.vue'))

const slideCaptchaRef = ref()
const emits = defineEmits(['ok'])

const state = reactive({
  requestId: '',
  failTip: '',
  successTip: '',
})

//生成滑块验证码
const onGenerate = async () => {
  slideCaptchaRef.value.startRequestGenerate()
  const res = await new CaptchaApi().generate({ captchaId: state.requestId }).catch(() => {
    slideCaptchaRef.value.endRequestGenerate(null, null)
  })
  if (res?.success && res.data) {
    state.requestId = res.data.id || ''
    slideCaptchaRef.value.endRequestGenerate(res.data.backgroundImage, res.data.sliderImage)
  }
}

//验证滑块验证码
const onFinish = async (data: any) => {
  slideCaptchaRef.value.startRequestVerify()
  const res = await new CaptchaApi().check(data, { captchaId: state.requestId }).catch(() => {
    state.failTip = '服务异常,请稍后重试'
    slideCaptchaRef.value.endRequestVerify(false)
  })
  if (res?.success && res.data) {
    let success = res.data.result === 0
    state.failTip = res.data.result == 1 ? '验证未通过,拖动滑块将悬浮图像正确合并' : '验证超时, 请重新操作'
    state.successTip = '验证通过'
    slideCaptchaRef.value.endRequestVerify(success)
    if (success) {
      //验证成功
      emits('ok', { captchaId: state.requestId, track: data })
    } else {
      setTimeout(() => {
        onGenerate()
      }, 1000)
    }
  }
}

//刷新滑块验证码
const refresh = () => {
  slideCaptchaRef.value?.handleRefresh()
}

defineExpose({
  refresh,
})
</script>

<style scoped lang="scss"></style>

 3. 孙组件 slide-captcha.vue (滑块实现关键组件)

//slide-captcha.vue (滑块实现关键组件)
<template>
  <div class="captcha">
    <div class="captcha__main" :style="imgWrapperStyle">
      <img v-if="state.src" :src="state.src" class="captcha_background" alt="background" ref="backgroundRef" />
      <img
        v-if="state.sliderSrc"
        :src="state.sliderSrc"
        class="captcha_slider"
        alt="slider"
        ref="slider"
        :class="{ goFirst: state.isOk, goKeep: state.isKeep }"
        @mousedown="handleDragStart"
        @touchstart="handleDragStart"
      />
      <div class="captcha_message" v-if="state.showVerifyTip">
        <div class="captcha_message__icon">
          <svg v-if="state.isPassing" width="28" height="28" viewBox="0 0 28 28" xmlns="http://www.w3.org/2000/svg">
            <g stroke="#fff" stroke-width="1.5" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
              <path
                d="M22.776 4.073A13.2 13.2 0 0 0 14 .75C6.682.75.75 6.682.75 14S6.682 27.25 14 27.25 27.25 21.318 27.25 14c0-.284-.009-.566-.027-.845"
              ></path>
              <path d="M7 12.5l7 7 13-13"></path>
            </g>
          </svg>
          <svg v-else width="28" height="28" viewBox="0 0 28 28" xmlns="http://www.w3.org/2000/svg">
            <g stroke="#fff" stroke-width="1.5" fill="none" fill-rule="evenodd">
              <circle cx="14" cy="14" r="13.25"></circle>
              <path stroke-linecap="round" stroke-linejoin="round" d="M8.75 8.75l10.5 10.5M19.25 8.75l-10.5 10.5"></path>
            </g>
          </svg>
        </div>
        <div class="captcha_message__text">{{ state.isPassing ? successTip : failTip }}</div>
      </div>
      <div class="captcha_message loadding" v-if="state.showGenerateLoadding">
        <div class="captcha_message__icon captcha_message__icon--loadding"></div>
        <div class="captcha_message__text">加载中...</div>
      </div>
      <div class="captcha_message" v-if="state.showVerifyLoadding">
        <div class="captcha_message__icon captcha_message__icon--loadding"></div>
        <div class="captcha_message__text"></div>
      </div>
    </div>
    <div class="captcha__bar" :style="dragVerifyStyle" ref="dragVerify">
      <div class="captcha_progress_bar" :class="{ goFirst2: state.isOk }" ref="progressBar" :style="progressBarStyle"></div>
      <div class="captcha_progress_bar__text" :style="textStyle">{{ state.tracks.length > 0 || state.isPassing ? '' : text }}</div>
      <div
        class="captcha_handler"
        :class="{ goFirst: state.isOk }"
        :style="handlerStyle"
        ref="handler"
        @mousedown="handleDragStart"
        @touchstart="handleDragStart"
      >
        <svg v-if="state.isPassing" width="16" height="16" viewBox="0 0 28 28" xmlns="http://www.w3.org/2000/svg">
          <g stroke="rgb(118, 198, 29)" stroke-width="1.5" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
            <path
              d="M22.776 4.073A13.2 13.2 0 0 0 14 .75C6.682.75.75 6.682.75 14S6.682 27.25 14 27.25 27.25 21.318 27.25 14c0-.284-.009-.566-.027-.845"
            ></path>
            <path d="M7 12.5l7 7 13-13"></path>
          </g>
        </svg>
        <svg v-else :style="handlerSvgStyle" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="819">
          <path
            d="M500.864 545.728a47.744 47.744 0 0 0 6.72-48.896 24.704 24.704 0 0 0-4.48-8.384L240.256 193.088a34.24 34.24 0 0 0-28.608-17.408 34.24 34.24 0 0 0-25.856 12.864 46.592 46.592 0 0 0 0 59.52l238.08 264.512-238.08 264.512a46.592 46.592 0 0 0-1.088 59.52 32 32 0 0 0 50.56 0l265.6-290.88z"
            p-id="820"
          ></path>
          <path
            d="M523.84 248.064l236.992 264.512-238.08 264.512a46.592 46.592 0 0 0 0 59.52 32 32 0 0 0 50.56 0l265.6-292.608a47.744 47.744 0 0 0 6.72-48.832 24.704 24.704 0 0 0-4.48-8.448L578.304 191.36a34.24 34.24 0 0 0-55.552-2.816 46.592 46.592 0 0 0 1.088 59.52z"
            p-id="821"
          ></path>
        </svg>
      </div>
    </div>
    <div class="captcha__actions" v-if="showRefresh && !state.isPassing">
      <a class="captcha__action" @click="handleRefresh">
        <svg fill="#FFF" width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg">
          <path
            d="M10,4 C12.0559549,4 13.9131832,5.04358655 15.0015086,6.68322231 L15,5.5 C15,5.22385763 15.2238576,5 15.5,5 C15.7761424,5 16,5.22385763 16,5.5 L16,8.5 C16,8.77614237 15.7761424,9 15.5,9 L12.5,9 C12.2238576,9 12,8.77614237 12,8.5 C12,8.22385763 12.2238576,8 12.5,8 L14.5842317,8.00000341 C13.7999308,6.20218044 12.0143541,5 10,5 C7.23857625,5 5,7.23857625 5,10 C5,12.7614237 7.23857625,15 10,15 C11.749756,15 13.3431487,14.0944653 14.2500463,12.6352662 C14.3958113,12.4007302 14.7041063,12.328767 14.9386423,12.4745321 C15.1731784,12.6202971 15.2451415,12.9285921 15.0993765,13.1631281 C14.0118542,14.9129524 12.0990688,16 10,16 C6.6862915,16 4,13.3137085 4,10 C4,6.6862915 6.6862915,4 10,4 Z"
            fill-rule="nonzero"
          ></path>
        </svg>
      </a>
    </div>
  </div>
</template>

<script lang="ts" setup name="my-slide-captcha">
import { reactive, computed, ref, onMounted, onBeforeMount, onUnmounted } from 'vue'

const props = defineProps({
  width: {
    type: [Number, String],
    default: 340,
  },
  height: {
    type: [Number, String],
    default: 212,
  },
  barHeight: {
    type: Number,
    default: 40,
  },
  handlerIconWidth: {
    type: Number,
    default: 16,
  },
  handlerIconHeigth: {
    type: Number,
    default: 16,
  },
  background: {
    type: String,
    default: '#eee',
  },
  circle: {
    type: Boolean,
    default: false,
  },
  radius: {
    type: String,
    default: '4px',
  },
  text: {
    type: String,
    default: '按住滑块拖动',
  },
  progressBarBg: {
    type: String,
    default: '#76c61d',
  },
  successTip: {
    type: String,
    default: '验证通过',
  },
  failTip: {
    type: String,
    default: '验证未通过,拖动滑块将悬浮图像正确合并',
  },
  showRefresh: {
    type: Boolean,
    default: true,
  },
})

const emits = defineEmits(['finish', 'refresh'])

const backgroundRef = ref()
const slider = ref()
const dragVerify = ref()
const progressBar = ref()
const handler = ref()

const state = reactive({
  isMoving: false,
  x: 0,
  y: 0,
  isOk: false,
  isKeep: false,
  isFinish: false,
  tracks: [],
  startSlidingTime: new Date(),
  showVerifyTip: false,
  showVerifyLoadding: false,
  showGenerateLoadding: false,
  src: '',
  sliderSrc: '',
  isPassing: false,
  width: 340,
})

const imgWrapperStyle = computed(() => {
  return {
    width: props.width + 'px',
    height: props.height + 'px',
    //position: 'relative',
    overflow: 'hidden',
  }
})
const dragVerifyStyle = computed(() => {
  return {
    width: props.width + 'px',
    height: props.barHeight + 'px',
    lineHeight: props.barHeight + 'px',
    background: props.background,
    borderRadius: props.circle ? props.barHeight / 2 + 'px' : props.radius,
  }
})
const progressBarStyle = computed(() => {
  return {
    background: props.progressBarBg,
    height: props.barHeight + 'px',
    borderRadius: props.circle ? props.barHeight / 2 + 'px 0 0 ' + props.barHeight / 2 + 'px' : props.radius,
  }
})
const textStyle = computed(() => {
  return {
    height: props.barHeight + 'px',
    width: props.width + 'px',
  }
})
const handlerStyle = computed(() => {
  return {
    width: props.barHeight + 'px',
    height: props.barHeight + 'px',
  }
})
const handlerSvgStyle = computed(() => {
  return {
    width: props.handlerIconWidth + 'px',
    height: props.handlerIconHeigth + 'px',
  }
})

onMounted(() => {
  const dragEl = dragVerify.value
  dragEl.style.setProperty('--textColor', '#333')
  let width = dragEl.clientWidth
  width = width > 0 ? width : state.width
  dragEl.style.setProperty('--width', Math.floor(width / 2) + 'px')
  dragEl.style.setProperty('--pwidth', -Math.floor(width / 2) + 'px')
  document.documentElement.style.setProperty('--my-captcha-width', width + 'px')
  handleRefresh()
})

const onLayoutResize = () => {
  const width = dragVerify.value?.clientWidth
  if (width > 0) document.documentElement.style.setProperty('--my-captcha-width', width + 'px')
}

// 页面加载前
onBeforeMount(() => {
  document.documentElement.style.setProperty('--my-captcha-width', state.width + 'px')
  window.addEventListener('resize', onLayoutResize)
})
// 页面卸载时
onUnmounted(() => {
  window.removeEventListener('resize', onLayoutResize)
})

// 开始请求生成图片时调用
const startRequestGenerate = () => {
  reset()
  state.showGenerateLoadding = true
}
// 结束请求生成图片时调用
const endRequestGenerate = (src: string, sliderSrc: string) => {
  state.showGenerateLoadding = false
  state.src = src
  state.sliderSrc = sliderSrc
}
// 开始请求校验时调用
const startRequestVerify = () => {
  state.showVerifyLoadding = true
}
// 结束请求校验时调用
const endRequestVerify = (isPassing: boolean) => {
  state.isPassing = isPassing
  state.showVerifyLoadding = false
  state.showVerifyTip = true
}
// 重置
const reset = () => {
  state.x = 0
  state.y = 0
  state.tracks = []
  state.isMoving = false
  state.isFinish = false
  state.showGenerateLoadding = false
  state.showVerifyLoadding = false
  state.showVerifyTip = false
  state.isPassing = false

  if (progressBar.value) progressBar.value.style.width = 0
  if (slider.value) slider.value.style.left = 0
  if (handler.value) handler.value.style.left = 0
}

//解绑事件
const removeEventListeners = () => {
  window.removeEventListener('touchmove', handleDragMoving)
  window.removeEventListener('touchend', handleDragFinish)
  window.removeEventListener('mousemove', handleDragMoving)
  window.removeEventListener('mouseup', handleDragFinish)
}
//开始拖拽
const handleDragStart = (e: any) => {
  e?.preventDefault()
  if (!state.isPassing && state.src && state.sliderSrc && !state.isFinish) {
    window.addEventListener('touchmove', handleDragMoving)
    window.addEventListener('touchend', handleDragFinish)
    window.addEventListener('mousemove', handleDragMoving)
    window.addEventListener('mouseup', handleDragFinish)

    state.isMoving = true
    state.startSlidingTime = new Date()
    state.x = e.touches ? e.touches[0].pageX : e.clientX
    state.y = e.touches ? e.touches[0].pageY : e.clientY
    state.width = dragVerify.value.clientWidth
  }
}
//拖拽中
const handleDragMoving = (e: any) => {
  e?.preventDefault()
  if (state.isMoving && !state.isPassing && state.src && state.sliderSrc && !state.isFinish) {
    var _x = (e.touches ? e.touches[0].pageX : e.clientX) - state.x
    if (_x > 0 && _x <= state.width - props.barHeight) {
      var _y = (e.touches ? e.touches[0].pageY : e.clientY) - state.y

      handler.value.style.left = _x + 'px'
      progressBar.value.style.width = _x + props.barHeight / 2 + 'px'
      slider.value.style.left = _x + 'px'

      state.tracks.push({
        x: Math.round(_x),
        y: Math.round(_y),
        t: new Date().getTime() - state.startSlidingTime.getTime(),
      } as never)
    }
    if (_x <= 0) {
      handler.value.style.left = '0px'
      progressBar.value.style.width = '0px'
    }
    if (_x > state.width - props.barHeight) {
      handler.value.style.left = state.width - props.barHeight + 'px'
      progressBar.value.style.width = state.width + props.barHeight / 2 + 'px'
    }
  }
}
//拖拽结束
const handleDragFinish = (e: any) => {
  e?.preventDefault()
  if (state.isMoving && !state.isPassing && state.src && state.sliderSrc && !state.isFinish) {
    state.isMoving = false
    state.isFinish = true
    removeEventListeners()
    if (state.tracks.length > 0) {
      emits('finish', {
        backgroundImageWidth: backgroundRef.value.offsetWidth,
        backgroundImageHeight: backgroundRef.value.offsetHeight,
        sliderImageWidth: slider.value.offsetWidth,
        sliderImageHeight: slider.value.offsetHeight,
        startTime: state.startSlidingTime,
        endTime: new Date(),
        tracks: state.tracks,
      })
    } else {
      reset()
    }
  }
}
//刷新
const handleRefresh = () => {
  reset()
  emits('refresh')
}
//导出方法
defineExpose({
  startRequestGenerate,
  endRequestGenerate,
  startRequestVerify,
  endRequestVerify,
  handleRefresh,
})
</script>

<style scoped lang="scss">
.captcha {
  position: relative;
  user-select: none;
  display: flex;
  flex-direction: column;
  align-items: center;
}
.captcha__main {
  width: 100%;
  height: calc(var(--my-captcha-width) * (43 / 69));
  position: relative;
  background: rgb(244, 245, 246);
}
.captcha_background {
  width: 100%;
}
.captcha_slider {
  position: absolute;
  top: 0;
  left: 0;
  display: block;
  height: 100%;
}
.captcha_message {
  position: absolute;
  left: 0px;
  top: 0px;
  width: 100%;
  height: 100%;
  z-index: 999999;
  background-color: rgba(34, 34, 34, 0.85);
  display: flex;
  -webkit-box-pack: center;
  justify-content: center;
  -webkit-box-align: center;
  align-items: center;
  flex-direction: column;
}
.captcha_message__icon {
  width: 28px;
  height: 28px;
  margin: 0px auto;
}
.captcha_message__icon--loadding {
  border-radius: 50%;
  width: 24px;
  height: 24px;
  animation: 1s linear 0s infinite normal none running turn;
  background-image: url(data:image/png;base64,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);
  background-size: contain;
  background-position: center center;
  background-repeat: no-repeat;
}
.captcha_message.loadding {
  background-color: rgb(244 245 246);
}
.captcha_message__text {
  padding: 10px;
  color: rgb(255, 255, 255);
  display: inline-block;
  text-align: center;
  max-width: 200px;
  font-size: 14px;
}
.captcha_message.loadding .captcha_message__text {
  color: rgb(202, 202, 202);
}
.captcha__bar {
  position: relative;
  text-align: center;
  overflow: hidden;
  width: 100%;
  margin-top: 5px;
  border: 1px solid #dcdfe6;
}
.captcha_progress_bar {
  position: absolute;
  top: 0px;
  left: 0px;
  width: 0;
}
.captcha_progress_bar__text {
  position: absolute;
  top: 0px;
  width: 100%;
  font-size: 12px;
  color: transparent;
  -moz-user-select: none;
  -webkit-user-select: none;
  user-select: none;
  -o-user-select: none;
  -ms-user-select: none;
  background: -webkit-gradient(
    linear,
    left top,
    right top,
    color-stop(0, var(--textColor)),
    color-stop(0.4, var(--textColor)),
    color-stop(0.5, #fff),
    color-stop(0.6, var(--textColor)),
    color-stop(1, var(--textColor))
  );
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
  -webkit-text-size-adjust: none;
  -webkit-animation: slidetounlock 3s infinite;
  animation: slidetounlock 3s infinite;
}

.captcha_handler {
  position: absolute;
  top: 0px;
  left: 0px;
  cursor: move;
  background: rgb(255, 255, 255);
  display: flex;
  align-items: center;
  justify-content: center;
}

.captcha__actions {
  display: flex;
  -webkit-box-pack: justify;
  justify-content: space-between;
  -webkit-box-align: center;
  align-items: center;
  line-height: 20px;
  min-height: 20px;
  color: rgb(80, 80, 80);
  position: absolute;
  top: 0px;
  right: 0px;
  opacity: 0.8;
  background: rgba(0, 0, 0, 0.12);
  padding: 0px 4px;
  &:hover {
    opacity: 1;
    background: rgba(0, 0, 0, 0.2);
  }
}

.captcha__action__text {
  color: rgb(80, 80, 80);
  font-size: 14px !important;
}

.captcha__action {
  display: flex;
  align-items: center;
  cursor: pointer;
  text-decoration: none;
}

.goFirst {
  left: 0px !important;
  transition: left 0.5s;
}
.goKeep {
  transition: left 0.2s;
}
.goFirst2 {
  width: 0px !important;
  transition: width 0.5s;
}

@keyframes slidetounlock {
  0% {
    background-position: var(--pwidth) 0;
  }
  100% {
    background-position: var(--width) 0;
  }
}
@keyframes slidetounlock2 {
  0% {
    background-position: var(--pwidth) 0;
  }
  100% {
    background-position: var(--pwidth) 0;
  }
}

@keyframes turn {
  0% {
    -webkit-transform: rotate(0deg);
  }
  25% {
    -webkit-transform: rotate(90deg);
  }
  50% {
    -webkit-transform: rotate(180deg);
  }
  75% {
    -webkit-transform: rotate(270deg);
  }
  100% {
    -webkit-transform: rotate(360deg);
  }
}
</style>
二、使用举例(以发送短验证码时滑块验证为例,关键看引入为MyCaptchaDialog的组件) 

【特别注意】滑块验证确认后调用后端接口时,需要把captchaId及track再次传给后端再次验证 

//使用举例(发送短验证码时的滑块验证)
<template>
  <div class="w100">
    <el-input text :maxlength="props.maxlength" placeholder="请输入验证码" autocomplete="off" v-bind="$attrs">
      <template #prefix>
        <el-icon class="el-input__icon"><ele-Message /></el-icon>
      </template>
      <template #suffix>
        <el-button
          v-show="state.status !== 'countdown'"
          :loading="state.loading.getCode"
          type="primary"
          link
          :disabled="state.status === 'countdown'"
          @click.prevent.stop="onGetCode"
          >{{ text }}</el-button
        >
        <el-countdown
          v-show="state.status === 'countdown'"
          :format="state.changeText"
          :value="state.countdown"
          value-style="font-size:var(--el-font-size-base);color:var(--el-color-primary)"
          @change="onChange"
        />
      </template>
    </el-input>
    <MyCaptchaDialog ref="myCaptchaDialogRef" v-model="state.showDialog" @ok="onOk" />
  </div>
</template>

<script lang="ts" setup name="my-input-code">
import { reactive, defineAsyncComponent, ref, computed } from 'vue'
import { isMobile } from '/@/utils/test'
import { ElMessage } from 'element-plus'
import { CaptchaApi } from '/@/api/admin/Captcha'

const MyCaptchaDialog = defineAsyncComponent(() => import('/@/components/my-captcha/dialog.vue'))

const emits = defineEmits(['send'])

const props = defineProps({
  maxlength: {
    type: Number,
    default: 6,
  },
  seconds: {
    type: Number,
    default: 60,
  },
  startText: {
    type: String,
    default: '获取验证码',
  },
  changeText: {
    type: String,
    default: 's秒后重发',
  },
  endText: {
    type: String,
    default: '重新发送验证码',
  },
  mobile: {
    type: String,
    default: '',
  },
  validate: {
    type: Function,
    default: null,
  },
})

const myCaptchaDialogRef = ref()
const countdown = Date.now()

const state = reactive({
  status: 'ready',
  startText: props.startText,
  changeText: props.changeText,
  endText: props.endText,
  countdown: countdown,

  showDialog: false,
  codeId: '',
  loading: {
    getCode: false,
  },
})

//获取验证码文本
const text = computed(() => {
  return state.status === 'ready' ? state.startText : state.endText
})

//开始倒计时
const startCountdown = () => {
  state.status = 'countdown'
  state.countdown = Date.now() + (props.seconds + 1) * 1000
}

//点击获取验证码
const onGetCode = () => {
  if (state.status !== 'countdown') {
    if (props.validate) {
      props.validate(getCode)
    } else {
      getCode()
    }
  }
}

//监听倒计时
const onChange = (value: number) => {
  if (state.countdown != countdown && value < 1000) state.status = 'finish'
}

//验证通过
const onOk = async (data: any) => {
  state.showDialog = false

  //发送短信验证码
  state.loading.getCode = true
  const res = await new CaptchaApi()
    .sendSmsCode({
      mobile: props.mobile,
      captchaId: data.captchaId,
      track: data.track,
      codeId: state.codeId,
    })
    .catch(() => {})
    .finally(() => {
      state.loading.getCode = false
    })

  if (res?.success && res.data) {
    state.codeId = res.data
    emits('send', res.data)
    startCountdown()
  }
}

//获得验证码
const getCode = () => {
  //验证手机号
  if (!isMobile(props.mobile)) {
    ElMessage.warning({ message: '请输入正确的手机号码', grouping: true })
    return
  }

  state.showDialog = true
  //刷新滑块拼图
  myCaptchaDialogRef.value?.refresh()
}
</script>

<style scoped lang="scss">
:deep(.el-statistic__content) {
  font-size: var(--el-font-size-base);
}
</style>
<style lang="scss">
.my-captcha .el-dialog__body {
  padding-top: 10px;
}
.my-captcha .captcha__bar {
  border-color: var(--el-border-color) !important;
}
</style>

后端接口再次验证 CaptchaId 及 Track

/// <summary>
/// 发送短信验证码
/// </summary>
/// <param name="input"></param>
/// <returns></returns>
[AllowAnonymous]
[NoOprationLog]
public async Task<string> SendSmsCodeAsync(SendSmsCodeInput input)
{
    if (input.Mobile.IsNull())
    {
        throw ResultOutput.Exception("请输入手机号");
    }

    if (input.CaptchaId.IsNull() || input.Track == null)
    {
        throw ResultOutput.Exception("请完成安全验证");
    }

    //再次验证滑块(这里采用默认验证(结束会删除缓存),而前面前端拖动的验证完不能马上删除缓存需留到这里验证,因此需另外写个验证方法给前端拖动时验证用:
    var validateResult = _captcha.Validate(input.CaptchaId, input.Track);
    if (validateResult.Result != ValidateResultType.Success)
    {
        throw ResultOutput.Exception($"安全{validateResult.Message}");
    }

    var codeId = input.CodeId.IsNull() ? Guid.NewGuid().ToString() : input.CodeId;
    var code = StringHelper.GenerateRandomNumber();
    await Cache.SetAsync(CacheKeys.GetSmsCodeKey(input.Mobile, codeId), code, TimeSpan.FromMinutes(5));


    //发送短信
    var result = await _sendSMSService.SendSMS(input.Mobile, $"验证码为:{code},5分钟内有效!");

    return codeId;
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值