登录界面添加图形验证码,安排!

哈喽小伙伴们大家好呀!不知道大家也没有想过这样一个问题,我们在日常的项目开发中为了保障用户的信息安全,往往需要考虑这样一个场景,怎么能提高系统的安全性呢?可以从哪几个角度来考虑呢?大家遇到这种问题又是怎么考虑的呢?我们可以想到的一种方法就是通过添加滑块验证来实现.以下是一些具体的场景,大家可以参考参考.

  1. 防止恶意登录:滑块验证码可以通过人机识别,判断是否为真实用户操作。这样可以有效地抵御自动化脚本或机器人的登录尝试,提高系统的安全性。
  2. 防止暴力破解:滑块验证码可以增加密码破解的难度,因为攻击者需要通过正确滑动验证码才能进行密码的输入。这可以有效减少暴力破解密码的风险。
  3. 减少验证码繁琐性:相比传统的字符验证码,滑块验证码更加友好和易于使用。用户只需要通过简单的滑动操作完成验证,而不需要输入复杂的字符或数字。这可以提升用户体验并减少用户的繁琐操作。
  4. 提高用户安全感:添加滑块验证码可以增强用户的安全感。用户在登录过程中需要完成额外的验证步骤,这使他们对账户的安全性更有信心,并减少被盗号或欺诈行为的风险。

从上面的描述中,我们可以看到滑块验证还是很好的一种方法,而且实现简单.接下来,我们进入正题,详细为大家介绍一下实现的步骤吧.

实现步骤

1、注册并引入静态结构:

静态结构(内容写死,不需要改动,直接复制粘贴使用即可)

<template>
  <div class="imgModule">
    <div class="slide-verify" :style="{ width: canvasWidth + 'px' }" onselectstart="return false;">
      <!-- 图片加载遮蔽罩 -->
      <div :class="{ 'img-loading': isLoading }" :style="{ height: canvasHeight + 'px' }" v-if="isLoading" />
      <!-- 认证成功后的文字提示 -->
      <div class="success-hint" :style="{ height: canvasHeight + 'px' }" v-if="verifySuccess">{{ successHint }}</div>
      <!--刷新按钮-->
      <div class="refresh-icon" @click="refresh" />
      <!--前端生成-->
      <template v-if="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': verifyFail }]" />
      </template>
      <!-- 滑动条 -->
      <div class="slider"
           :class="{ 'verify-active': verifyActive, 'verify-success': verifySuccess, 'verify-fail': verifyFail }">
        <!--滑块-->
        <div class="slider-box" :style="{ width: sliderBoxWidth }">
          <!-- 按钮 -->
          <div class="slider-button" id="slider-button" :style="{ left: sliderButtonLeft }">
            <!-- 按钮图标 -->
            <div class="slider-button-icon" />
          </div>
        </div>
        <!--滑动条提示文字-->
        <span class="slider-hint">{{ sliderHint }}</span>
      </div>
    </div>
  </div>
</template>

<script>
//引入获取验证码的接口
import { getCaptcha, getSliderCaptcha } from  '@/api/admin/api.ts';

function sum(x, y) {
  return x + y;
}

function square(x) {
  return x * x;
}

// import { getCodeImg } from "@/api/login";
export default {
  name: 'sliderVerify',
  props: {
    // 阻塞块长度
    blockLength: {
      type: Number,
      default: 42,
    },
    // 阻塞块弧度
    blockRadius: {
      type: Number,
      default: 10,
    },
    // 画布宽度
    canvasWidth: {
      type: Number,
      default: 320,
    },
    // 画布高度
    canvasHeight: {
      type: Number,
      default: 155,
    },
    // 滑块操作提示
    sliderHint: {
      type: String,
      default: '向右滑动',
    },
    // 可允许的误差范围小;为1时,则表示滑块要与凹槽完全重叠,才能验证成功。默认值为5,若为 -1 则不进行机器判断
    accuracy: {
      type: Number,
      default: 3,
    },
    // 图片资源数组
    imageList: {
      type: Array,
      default: () => [],
    },
  },
  data() {
    return {
      // 前端校验
      isFrontCheck: false,
      // 校验进行状态
      verifyActive: false,
      // 校验成功状态
      verifySuccess: false,
      // 校验失败状态
      verifyFail: false,
      // 阻塞块对象
      blockObj: null,
      // 图片画布对象
      canvasCtx: null,
      // 阻塞块画布对象
      blockCtx: null,
      // 阻塞块宽度
      blockWidth: this.blockLength * 2,
      // 阻塞块的横轴坐标
      blockX: undefined,
      // 阻塞块的纵轴坐标
      blockY: undefined,
      // 图片对象
      image: undefined,
      // 移动的X轴坐标
      originX: undefined,
      // 移动的Y轴做坐标
      originY: undefined,
      // 拖动距离数组
      dragDistanceList: [],
      // 滑块箱拖动宽度
      sliderBoxWidth: 0,
      // 滑块按钮距离左侧起点位置
      sliderButtonLeft: 0,
      // 鼠标按下状态
      isMouseDown: false,
      // 图片加载提示,防止图片没加载完就开始验证
      isLoading: true,
      // 时间戳,计算滑动时长
      timestamp: null,
      // 成功提示
      successHint: '',
      // 随机字符串
      nonceStr: undefined,
    };
  },
  mounted() {
    this.init();
  },
  methods: {
    /* 初始化*/
    init() {
      this.initDom();
      this.bindEvents();
    },
    /* 初始化DOM对象*/
    initDom() {
      this.blockObj = this.$refs.block;
      if (this.isFrontCheck) {
        this.canvasCtx = this.$refs.canvas.getContext('2d');
        this.blockCtx = this.blockObj.getContext('2d');
        this.initImage();
      } else {
        this.getCaptcha();
      }
    },
    /* 后台获取验证码*/
    getCaptcha() {
      let self = this;
      //取后端默认值
      getSliderCaptcha().then((response) => {
        const data = response.data;
        self.nonceStr = data.nonceStr;
        self.$refs.block.src = data.blockSrc;
        self.$refs.block.style.top = data.blockY + 'px';
        self.$refs.canvas.src = data.canvasSrc;
      }).finally(() => {
        self.isLoading = false;
      });
    },
    /* 前端获取验证码*/
    initImage() {
      const image = this.createImage(() => {
        this.drawBlock();
        let { canvasWidth, canvasHeight, blockX, blockY, blockRadius, blockWidth } = this;
        this.canvasCtx.drawImage(image, 0, 0, canvasWidth, canvasHeight);
        this.blockCtx.drawImage(image, 0, 0, canvasWidth, canvasHeight);
        // 将抠图防止最左边位置
        let yAxle = blockY - blockRadius * 2;
        let ImageData = this.blockCtx.getImageData(blockX, yAxle, blockWidth, blockWidth);
        this.blockObj.width = blockWidth;
        this.blockCtx.putImageData(ImageData, 0, yAxle);
        // 图片加载完关闭遮蔽罩
        this.isLoading = false;
        // 前端校验设置特殊值
        this.nonceStr = 'loyer';
      });
      this.image = image;
    },
    /* 创建image对象*/
    createImage(onload) {
      const image = document.createElement('img');
      image.crossOrigin = 'Anonymous';
      image.onload = onload;
      image.onerror = () => {
        image.src = require('../../assets/images/bgImg.jpg');
      };
      image.src = this.getImageSrc();
      return image;
    },
    /* 获取imgSrc*/
    getImageSrc() {
      const len = this.imageList.length;
      return len > 0 ? this.imageList[this.getNonceByRange(0, len)] : `https://loyer.wang/view/ftp/wallpaper/${this.getNonceByRange(1, 1000)}.jpg`;
    },
    /* 根据指定范围获取随机数*/
    getNonceByRange(start, end) {
      return Math.round(Math.random() * (end - start) + start);
    },
    /* 绘制阻塞块*/
    drawBlock() {
      this.blockX = this.getNonceByRange(this.blockWidth + 10, this.canvasWidth - (this.blockWidth + 10));
      this.blockY = this.getNonceByRange(10 + this.blockRadius * 2, this.canvasHeight - (this.blockWidth + 10));
      this.draw(this.canvasCtx, 'fill');
      this.draw(this.blockCtx, 'clip');
    },
    /* 绘制事件*/
    draw(ctx, operation) {
      const PI = Math.PI;
      let { blockX: x, blockY: y, blockLength: l, blockRadius: r } = this;
      // 绘制
      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';
    },
    /* 事件绑定*/
    bindEvents() {
      // 监听鼠标按下事件
      document.getElementById('slider-button').addEventListener('mousedown', (event) => {
        this.startEvent(event.clientX, event.clientY);
      });
      // 监听鼠标移动事件
      document.addEventListener('mousemove', (event) => {
        this.moveEvent(event.clientX, event.clientY);
      });
      // 监听鼠标离开事件
      document.addEventListener('mouseup', (event) => {
        this.endEvent(event.clientX);
      });
      // 监听触摸开始事件
      document.getElementById('slider-button').addEventListener('touchstart', (event) => {
        this.startEvent(event.changedTouches[0].pageX, event.changedTouches[0].pageY);
      });
      // 监听触摸滑动事件
      document.addEventListener('touchmove', (event) => {
        this.moveEvent(event.changedTouches[0].pageX, event.changedTouches[0].pageY);
      });
      // 监听触摸离开事件
      document.addEventListener('touchend', (event) => {
        this.endEvent(event.changedTouches[0].pageX);
      });
    },
    /* 校验图片是否存在*/
    checkImgSrc() {
      if (this.isFrontCheck) {
        return true;
      }
      return !!this.$refs.canvas.src;
    },
    /* 滑动开始事件*/
    startEvent(originX, originY) {
      if (!this.checkImgSrc() || this.isLoading || this.verifySuccess) {
        return;
      }
      this.originX = originX;
      this.originY = originY;
      this.isMouseDown = true;
      this.timestamp = +new Date();
    },
    /* 滑动事件*/
    moveEvent(originX, originY) {
      if (!this.isMouseDown) {
        return false;
      }
      const moveX = originX - this.originX;
      const moveY = originY - this.originY;
      if (moveX < 0 || moveX + 40 >= this.canvasWidth) {
        return false;
      }
      this.sliderButtonLeft = moveX + 'px';
      let blockLeft = (this.canvasWidth - 40 - 20) / (this.canvasWidth - 40) * moveX;
      this.blockObj.style.left = blockLeft + 'px';
      this.verifyActive = true;
      this.sliderBoxWidth = moveX + 'px';
      this.dragDistanceList.push(moveY);
    },
    /* 滑动结束事件*/
    endEvent(originX) {
      if (!this.isMouseDown) {
        return false;
      }
      this.isMouseDown = false;
      if (originX === this.originX) {
        return false;
      }
      // 开始校验
      this.isLoading = true;
      // 校验结束
      this.verifyActive = false;
      // 滑动时长
      this.timestamp = +new Date() - this.timestamp;
      // 移动距离
      const moveLength = parseInt(this.blockObj.style.left);
      // 限制操作时长10S,超出判断失败
      if (this.timestamp > 10000) {
        this.verifyFailEvent();
      } else if (!this.turingTest()) {
        // 人为操作判定
        this.verifyFail = true;
        this.$emit('again');
      } else if (this.isFrontCheck) {
        // 是否前端校验
        const accuracy = this.accuracy <= 1 ? 1 : this.accuracy > 10 ? 10 : this.accuracy; // 容错精度值
        const spliced = Math.abs(moveLength - this.blockX) <= accuracy; // 判断是否重合
        if (!spliced) {
          this.verifyFailEvent();
        } else {
          // 设置特殊值,后台特殊处理,直接验证通过
          this.$emit('success', { nonceStr: this.nonceStr, value: moveLength });
        }
      } else {
        // 拖动完成,进行
        this.$emit('success', { nonceStr: this.nonceStr, value: moveLength });
      }
    },
    /* 图灵测试*/
    turingTest() {
      const arr = this.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; // 判断是否人为操作
    },
    /* 校验成功*/
    verifySuccessEvent() {
      console.log("成功啦")
      this.isLoading = false;
      this.verifySuccess = true;
      const elapsedTime = (this.timestamp / 1000).toFixed(1);
      if (elapsedTime < 1) {
        this.successHint = `仅仅${elapsedTime}S,你的速度快如闪电`;
      } else if (elapsedTime < 2) {
        this.successHint = `只用了${elapsedTime}S,这速度简直完美`;
      } else {
        this.successHint = `耗时${elapsedTime}S,争取下次再快一点`;
      }
    },
    /* 校验失败*/
    verifyFailEvent(msg) {
      this.verifyFail = true;
      this.$emit('fail', msg);
      this.refresh();
    },
    /* 刷新图片验证码*/
    refresh() {
      // 延迟class的删除,等待动画结束
      setTimeout(() => {
        this.verifyFail = false;
      }, 500);
      this.isLoading = true;
      this.verifyActive = false;
      this.verifySuccess = false;
      this.blockObj.style.left = 0;
      this.sliderBoxWidth = 0;
      this.sliderButtonLeft = 0;
      if (this.isFrontCheck) {
        // 刷新画布
        let { canvasWidth, canvasHeight } = this;
        this.canvasCtx.clearRect(0, 0, canvasWidth, canvasHeight);
        this.blockCtx.clearRect(0, 0, canvasWidth, canvasHeight);
        this.blockObj.width = canvasWidth;
        // 刷新图片
        this.image.src = this.getImageSrc();
      } else {
        this.getCaptcha();
      }
    },
  },
};
</script>

<style scoped>
.imgModule{
  padding: 24px 4px 24px 24px;
  background-color: #fff;
  width: 340px;
  border-radius: 12px;
  position: absolute;
  left: 50%;
  margin-left: -170px;
  top: 50%;
  margin-top: -300px;
}
.slide-verify {
  position: relative;
}

/*图片加载样式*/
.img-loading {
  position: absolute;
  top: 0;
  right: 0;
  left: 0;
  bottom: 0;
  z-index: 999;
  animation: loading 1.5s infinite;
  background-image: url(../../../assets/images/loading.png);
  /* background-image: url(../../assets/images/loading.svg); */
  background-repeat: no-repeat;
  background-position: center center;
  background-size: 100px;
  background-color: #737c8e;
  border-radius: 5px;
}

@keyframes loading {
  0% {
    opacity: .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;
  background: rgba(255, 255, 255, 0.8);
  color: #2CD000;
  font-size: large;
}

/*刷新按钮*/
.refresh-icon {
  position: absolute;
  right: 0;
  top: 0;
  width: 35px;
  height: 35px;
  cursor: pointer;
  background: url("../../../assets/images/addIcon.png") 0 -432px;
  /* background: url("../../assets/images/light.png") 0 -432px; */
  background-size: 35px 470px;
}

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

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

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

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

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

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

/*鼠标悬浮时的按钮样式*/
.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("../../../assets/images/addIcon.png") 0 -26px;
  /* background: url("../../assets/images/light.png") 0 -26px; */
  background-size: 35px 470px
}

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

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

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

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

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

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

/*校验失败时的按钮样式*/
.verify-fail .slider-button {
  height: 38px;
  top: -1px;
  border: 1px solid #f57a7a;
  background-color: #f57a7a !important;
  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>

2、在页面中引入组件:(一般登录的时候会用到这个组件),loginView.vue

import sliderVerify from "@/components/auth/sliderVerify.vue";

3、注册组件:

components: {
  sliderVerify
},

4、编写接口,向后端发送数据请求

export function getCaptcha(params) {
  return http.get("/sso/login/captcha", { params, responseType: "blob" });
}

export function getSliderCaptcha() {
  return http.get("/captcha/public");
}
export function checkSliderCaptcha(params: object) {
  return http.get("/captcha/public/check", {params});
}
export function getPublicNeed(params: object) {
  return http.get("/captcha/public/need", {params});
}

5、编写滑块出现和消失相关的逻辑:

出现背景:如果用户登录一次,而且账号和密码都正确的话,点击登录直接跳转到首页,不需要弹出滑块

如果用户第一次登录的时候账号或密码错误,则正常提示账号和密码错误,第二次正确输入账号和密码后会弹出滑块,让用户通过滑块进行再次确认.

滑块成功弹出:

//定义一个方法,表示滑块滑动成功,接受一个对象类型的参数
sliderSuccess(callback: object) {
//定义一个对象,用callback的值进行初始化
  let params = {
    imageKey: callback.nonceStr,
    slidingDistance: callback.value,
  }
  //调用 checkSliderCaptcha 函数,并在异步操作完成后执行回调函数。
  checkSliderCaptcha(params).then((res) => {
    console.log("加测", res)
    console.log(this.accountParams)
    //检查 res.data.pass 是否为真,即滑动验证是否通过。
    if (res.data.pass) {
    //如果滑动验证通过,将 showSlider 设为 false,隐藏滑动验证码组件。
      this.showSlider = false
      if (this.clickType !== 'hasPwd') {
        let loginParams = {
          imageKey: callback.nonceStr,
          slidingDistance: callback.value,
          password: this.info.password,
          username: this.info.username

        }
        //登录逻辑
        login(loginParams)
            .then((res) => {
              this.showSlider = false;
              setToken(res.data.accessToken);
              setName(res.data.account.name);
              setRoleShowName(res.data.orgShowName);
              this.$sentry.configureScope(function (scope) {
                scope.setUser({
                  name: res.data.account.name,
                  phone: res.data.account.phone,
                });
              });
              let menuItemMap = {};
              for (const item of menusRoutes) {
                menuItemMap[item.meta.code] = item;
              }
              let accountMenu = res.data.menus;
              let menu = this.getUserMenu(menuItemMap, accountMenu);
              setMenus(menu);
              this.redirectIndexPag(res.data.menus);
              this.hasInfo();
            }).catch((e) => {
          this.isError = true;
        }).finally(() => {
          this.lock = false;
          this.showSlider = false;
        });
      } else {
        const phone = this.smsParams.account
        if (!checkMobileFormat(phone)) {
          ElMessage.warning("请输入正确格式的手机号");
          return;
        }
        if (this.time !== 60) {
          return;
        },
      }
    } else {
      this.$refs.sliderVerify.refresh();
    }
  })

},

滑块弹出失败:

sliderFail() {
  this.showSlider = false;
},

在登录的时候要编写一些相关的逻辑:

首先,定义一个变量用来表示滑块的弹出与否.

showSlider: false

登录方法内部实现:

login() {
  let getNeedParams = {
    account: this.info.username
  }
  getPublicNeed(getNeedParams).then((res) => {
        console.log("res88888", res.data)
        if (res.data) {
          this.showSlider = true
          return
        }
        else
        {

   (this.$refs.info as typeof ElForm).validate((valid: any) => {
    if (valid) {
      // const { username, password } = this.info;
      let loginParams = {


        password: this.info.password,
        username: this.info.username

      }
      login(loginParams)
        .then(({ data }: { data: any }) => {
          localStorage.setItem("isProvinceInstitution",data.isProvinceInstitution)
          // let maxAge = (data.expiration - data.issuedAt) / 1000;
          let maxAge = 48 * 60 * 60;
          document.cookie = `accessToken=${data.accessToken}; max-age=${maxAge}; path=/`;
          document.cookie = `refreshToken=${data.refreshToken}; max-age=${maxAge}; path=/`;
          // let expirationTime = + new Date() + 8 * 60 * 60 * 1000;
          localStorage.setItem('expirationTime', data.expiration);
          setUserRole(data.roles[0]);
          //用户类别
          if (data.roles[0] === 'ROLE_USER') {
            this.$router.push('/proj-apls/center/1');
          }
            
        })
        .catch(() => {
          ElMessage.error('用户名或密码错误');
        });
    } else {
      return false;
    }
  });
        }

  })

},

下面是对代码的逐行解释:

let getNeedParams = {

account: this.info.username

}

定义一个参数对象,拿到表单中提交的账号(用户名)

getPublicNeed(getNeedParams).then((res) => {

console.log("res88888", res.data)

if (res.data) {

this.showSlider = true

return

}

定义一个方法,用来确定滑块弹出与否,将刚刚定义的对象作为参数传入,如果登录数据正确,则将showSlider 值置为true.表示滑块显示.后面就是正常的登录逻辑了,这里不做赘述,有问题可以评论区提出来,我看到会回答~

具体的运行结果这里不展示了,因为需要跟后端交互,每次麻烦人家后端小哥怪不好意思的,大家可以自行体验一下~

好啦,本期文章就到这里啦,我们下期见!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

爱学英语的程序媛

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值