Java实现滑块拼图验证码校验
Java实现滑块拼图验证码校验
·
Java实现滑块拼图验证码校验
1.后端-获取滑块实现思路
1.在若干原图中随机一张原图,生成滑块拼图验证码信息。
2.随机一个token,把(X,Y)坐标值保存到 Redis中,方便做效验
3.返回前端,两个Base64字符串图片信息,Y坐标值和 token。
2、后端-校验滑块拼图验证码接口
获取前端传入的 moveX坐标值和 token。
通过token,获取 Redis中的 (X,Y)坐标值,注意有效期。
校验 moveX与X的差的绝对值是否在阈值误差范围内(比如阈值误差为5)。
在阈值误差范围内,返回前端验证通过,否则返回前端验证不通过。
效果如图
package com.icourt.lawtrust.utils;
import com.icourt.lawtrust.module.login.dto.CaptchaDto;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang.math.RandomUtils;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.core.io.support.ResourcePatternResolver;
import org.springframework.util.StopWatch;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.util.List;
import java.util.*;
/**
* @author syl
* @title PuzzleCaptchaUtil
* @description 拼图验证码工具类
* @date 2020-11-12 15:51
*/
@Slf4j
public class PuzzleCaptchaUtil {
/*网络图片地址*/
private final static String IMG_URL = "";
/*本地图片地址*/
private static final String IMG_PATH = "image/picture/captcha/%s.png";
/**
* @param captchaDto
* @author syl
* @description 获取验证码拼图(生成的抠图和带抠图阴影的大图及抠图坐标)
* @date 2022-1-7 10:32
*/
public static CaptchaDto getCaptcha(CaptchaDto captchaDto) {
//参数校验
checkCaptcha(captchaDto);
//获取画布的宽高
int canvasWidth = captchaDto.getCanvasWidth();
int canvasHeight = captchaDto.getCanvasHeight();
//获取阻塞块的宽高/半径
int blockWidth = captchaDto.getBlockWidth();
int blockHeight = captchaDto.getBlockHeight();
int blockRadius = captchaDto.getBlockRadius();
//获取资源图
BufferedImage canvasImage = getBufferedImage(captchaDto.getPlace());
//调整原图到指定大小
canvasImage = imageResize(canvasImage, canvasWidth, canvasHeight);
//随机生成阻塞块坐标
int blockX = getNonceByRange(blockWidth, canvasWidth - blockWidth - 10);
int blockY = getNonceByRange(10, canvasHeight - blockHeight + 1);
//阻塞块
BufferedImage blockImage = new BufferedImage(blockWidth, blockHeight, BufferedImage.TYPE_4BYTE_ABGR);
//新建的图像根据轮廓图颜色赋值,源图生成遮罩
cutByTemplate(canvasImage, blockImage, blockWidth, blockHeight, blockRadius, blockX, blockY);
//缓存移动横坐标
String ticket = UUID.randomUUID().toString().replaceAll("-", "");
//设置返回参数
captchaDto.setTicket(ticket);
captchaDto.setBlockY(blockY);
captchaDto.setBlockX(blockX);
captchaDto.setBlockSrc(toBase64(blockImage, "png"));
captchaDto.setCanvasSrc(toBase64(canvasImage, "png"));
return captchaDto;
}
/**
* @param captchaDto
* @return void
* @author syl
* @description 入参校验设置默认值
* @date 2022-1-7 10:49
*/
private static void checkCaptcha(CaptchaDto captchaDto) {
//设置画布宽度默认值
if (captchaDto.getCanvasWidth() == null) {
captchaDto.setCanvasWidth(320);
}
//设置画布高度默认值
if (captchaDto.getCanvasHeight() == null) {
captchaDto.setCanvasHeight(155);
}
//设置阻塞块宽度默认值
if (captchaDto.getBlockWidth() == null) {
captchaDto.setBlockWidth(65);
}
//设置阻塞块高度默认值
if (captchaDto.getBlockHeight() == null) {
captchaDto.setBlockHeight(55);
}
//设置阻塞块凹凸半径默认值
if (captchaDto.getBlockRadius() == null) {
captchaDto.setBlockRadius(9);
}
//设置图片来源默认值
if (captchaDto.getPlace() == null) {
captchaDto.setPlace(0);
}
}
/**
* @param start
* @param end
* @return int
* @author syl
* @description 获取指定范围内的随机数
* @date 2022-1-7 9:41
*/
public static int getNonceByRange(int start, int end) {
Random random = new Random();
return random.nextInt(end - start + 1) + start;
}
/**
* @param place
* @return java.awt.image.BufferedImage
* @author syl
* @description 获取验证码资源图
* @date 2022-1-7 10:48
*/
private static BufferedImage getBufferedImage(Integer place) {
try {
//随机图片
int nonce = getNonceByRange(0, 1000);
//获取网络资源图片
if (0 == place) {
StopWatch stopWatch = new StopWatch();
stopWatch.start();
String imgUrl = String.format(IMG_URL, nonce);
URL url = new URL(imgUrl);
BufferedImage read = ImageIO.read(url.openStream());
stopWatch.stop();
log.info(stopWatch.prettyPrint());
return read;
}
//获取本地图片
else {
int randNum = new Random().nextInt(12) + 1;
ClassLoader loader = Thread.currentThread().getContextClassLoader();
if (loader == null) {
loader = ClassLoader.getSystemClassLoader();
}
InputStream is = loader.getResourceAsStream(String.format(IMG_PATH, randNum));
BufferedImage read = ImageIO.read(is);
return read;
}
} catch (Exception e) {
log.error("获取拼图资源失败", e);
}
return null;
}
/**
* 获取图片,由于spring boot打包成jar之后,获取到获取不到resources里头的图片,对此进行处理
*
* @param path
* @return
* @author syl
* @date 2022年1月6日
*/
public static List<File> queryFileList(String path) {
//获取容器资源解析器
ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
List<File> filelist = new ArrayList<File>();
// 获取远程服务器IP和端口
try {
//获取所有匹配的文件
Resource[] resources = resolver.getResources(path);
for (Resource resource : resources) {
//获得文件流,因为在jar文件中,不能直接通过文件资源路径拿到文件,但是可以在jar包中拿到文件流
InputStream stream = resource.getInputStream();
String targetFilePath = resource.getFilename();
File ttfFile = new File(targetFilePath);
FileUtils.copyInputStreamToFile(stream, ttfFile);
filelist.add(ttfFile);
}
} catch (Exception e) {
log.error("解析失败", e);
// e.printStackTrace();
}
return filelist;
}
/**
* @param bufferedImage
* @param width
* @param height
* @return java.awt.image.BufferedImage
* @author syl
* @description 调整图片大小
* @date 2022-1-7 9:41
*/
public static BufferedImage imageResize(BufferedImage bufferedImage, int width, int height) {
Image image = bufferedImage.getScaledInstance(width, height, Image.SCALE_SMOOTH);
BufferedImage resultImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
Graphics2D graphics2D = resultImage.createGraphics();
graphics2D.drawImage(image, 0, 0, null);
graphics2D.dispose();
return resultImage;
}
/**
* @param canvasImage
* @param blockImage
* @param blockWidth
* @param blockHeight
* @param blockRadius
* @param blockX
* @param blockY
* @return void
* @author syl
* @description 抠图,并生成阻塞块
* @date 2022-1-7 9:54
*/
private static void cutByTemplate(BufferedImage canvasImage, BufferedImage blockImage, int blockWidth, int blockHeight, int blockRadius, int blockX, int blockY) {
BufferedImage waterImage = new BufferedImage(blockWidth, blockHeight, BufferedImage.TYPE_4BYTE_ABGR);
//阻塞块的轮廓图
int[][] blockData = getBlockData(blockWidth, blockHeight, blockRadius);
//创建阻塞块具体形状
for (int i = 0; i < blockWidth; i++) {
for (int j = 0; j < blockHeight; j++) {
try {
//原图中对应位置变色处理
if (blockData[i][j] == 1) {
//背景设置为黑色
waterImage.setRGB(i, j, Color.BLACK.getRGB());
blockImage.setRGB(i, j, canvasImage.getRGB(blockX + i, blockY + j));
//轮廓设置为白色,取带像素和无像素的界点,判断该点是不是临界轮廓点
if (blockData[i + 1][j] == 0 || blockData[i][j + 1] == 0 || blockData[i - 1][j] == 0 || blockData[i][j - 1] == 0) {
blockImage.setRGB(i, j, Color.WHITE.getRGB());
waterImage.setRGB(i, j, Color.WHITE.getRGB());
}
}
//这里把背景设为透明
else {
blockImage.setRGB(i, j, Color.TRANSLUCENT);
waterImage.setRGB(i, j, Color.TRANSLUCENT);
}
} catch (ArrayIndexOutOfBoundsException e) {
//防止数组下标越界异常
}
}
}
//在画布上添加阻塞块水印
addBlockWatermark(canvasImage, waterImage, blockX, blockY);
}
/**
* @param blockWidth
* @param blockHeight
* @param blockRadius
* @return int[][]
* @author syl
* @description 构建拼图轮廓轨迹
* @date 2022-1-7 9:53
*/
private static int[][] getBlockData(int blockWidth, int blockHeight, int blockRadius) {
int[][] data = new int[blockWidth][blockHeight];
double po = Math.pow(blockRadius, 2);
//随机生成两个圆的坐标,在4个方向上 随机找到2个方向添加凸/凹
//凸/凹1
int face1 = RandomUtils.nextInt(4);
//凸/凹2
int face2;
//保证两个凸/凹不在同一位置
do {
face2 = RandomUtils.nextInt(4);
} while (face1 == face2);
//获取凸/凹起位置坐标
int[] circle1 = getCircleCoords(face1, blockWidth, blockHeight, blockRadius);
int[] circle2 = getCircleCoords(face2, blockWidth, blockHeight, blockRadius);
//随机凸/凹类型
int shape = getNonceByRange(0, 1);
//圆的标准方程 (x-a)²+(y-b)²=r²,标识圆心(a,b),半径为r的圆
//计算需要的小图轮廓,用二维数组来表示,二维数组有两张值,0和1,其中0表示没有颜色,1有颜色
for (int i = 0; i < blockWidth; i++) {
for (int j = 0; j < blockHeight; j++) {
data[i][j] = 0;
//创建中间的方形区域
if ((i >= blockRadius && i <= blockWidth - blockRadius && j >= blockRadius && j <= blockHeight - blockRadius)) {
data[i][j] = 1;
}
double d1 = Math.pow(i - Objects.requireNonNull(circle1)[0], 2) + Math.pow(j - circle1[1], 2);
double d2 = Math.pow(i - Objects.requireNonNull(circle2)[0], 2) + Math.pow(j - circle2[1], 2);
//创建两个凸/凹
if (d1 <= po || d2 <= po) {
data[i][j] = shape;
}
}
}
return data;
}
/**
* @param face
* @param blockWidth
* @param blockHeight
* @param blockRadius
* @return int[]
* @author syl
* @description 根据朝向获取圆心坐标
* @date 2022-1-7 9:50
*/
private static int[] getCircleCoords(int face, int blockWidth, int blockHeight, int blockRadius) {
//上
if (0 == face) {
return new int[]{blockWidth / 2 - 1, blockRadius};
}
//左
else if (1 == face) {
return new int[]{blockRadius, blockHeight / 2 - 1};
}
//下
else if (2 == face) {
return new int[]{blockWidth / 2 - 1, blockHeight - blockRadius - 1};
}
//右
else if (3 == face) {
return new int[]{blockWidth - blockRadius - 1, blockHeight / 2 - 1};
}
return null;
}
/**
* @param canvasImage
* @param blockImage
* @param x
* @param y
* @return void
* @author syl
* @description 在画布上添加阻塞块水印
* @date 2022-1-7 11:18
*/
private static void addBlockWatermark(BufferedImage canvasImage, BufferedImage blockImage, int x, int y) {
Graphics2D graphics2D = canvasImage.createGraphics();
graphics2D.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_ATOP, 0.8f));
graphics2D.drawImage(blockImage, x, y, null);
graphics2D.dispose();
}
/**
* @param bufferedImage
* @param type
* @return java.lang.String
* @author syl
* @description BufferedImage转BASE64
* @date 2022-1-7 9:41
*/
public static String toBase64(BufferedImage bufferedImage, String type) {
try {
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
ImageIO.write(bufferedImage, type, byteArrayOutputStream);
String base64 = Base64.getEncoder().encodeToString(byteArrayOutputStream.toByteArray());
return String.format("data:image/%s;base64,%s", type, base64);
} catch (IOException e) {
log.error("图片资源转换BASE64失败", e);
}
return null;
}
}
- 返回前端滑块的信息,带一个ticket,redis缓存BlockX
CaptchaDto captcha = PuzzleCaptchaUtil.getCaptcha(captchaDto);
/*
**缓存验证码
*/
redisTemplate.opsForValue().set(CAPTCHA_KEY_PREFIX + captcha.getTicket(), String.valueOf(captcha.getBlockX()), captchaExpire, TimeUnit.MINUTES);
- 效验滑块核心功能
/**
* @return com.icourt.lawtrust.module.login.vo.CaptchaVo
* @author: shaoyingle
* @desc:检查滑块是否正确
* @date 2022/1/10 9:35 上午
*/
@Override
public void checkCaptcha(String ticket, String moveLength) {
if (StringUtils.isBlank(ticket)) {
throw new BusinessException("ticket为空",true);
}
if (StringUtils.isBlank(moveLength)) {
throw new BusinessException("moveLength为空");
}
// 校验验证码是否存在
if (!redisTemplate.hasKey(CAPTCHA_KEY_PREFIX + ticket)) {
throw new BusinessException("验证码已失效");
}
// 获取缓存验证码
int redisValue = Integer.parseInt(redisTemplate.opsForValue().get(CAPTCHA_KEY_PREFIX + ticket).toString());
// 一次性的用完就删
redisTemplate.delete(CAPTCHA_KEY_PREFIX + ticket);
// 根据移动距离判断验证是否成功
if (Math.abs(redisValue - Integer.parseInt(moveLength)) > SysLabelConstant.ALLOW_DEVIATION) {
throw new BusinessException("验证未通过,拖动滑块将悬浮图像正确合并");
}
}
package com.icourt.lawtrust.module.login.dto;
import io.swagger.annotations.ApiModel;
import lombok.Data;
/**
* @author syl
* @title Captcha
* @description 验证码拼图
* @date 2022-1-7
*/
@ApiModel("验证码拼图")
@Data
public class CaptchaDto {
//随机字符串
private String ticket;
//验证值
private String value;
//生成的画布的base64
private String canvasSrc;
//画布宽度
private Integer canvasWidth;
//画布高度
private Integer canvasHeight;
//生成的阻塞块的base64
private String blockSrc;
//阻塞块宽度
private Integer blockWidth;
//阻塞块高度
private Integer blockHeight;
//阻塞块凸凹半径
private Integer blockRadius;
// 阻塞块的横轴坐标
private Integer blockX;
// 阻塞块的纵轴坐标
private Integer blockY;
//图片获取位置
private Integer place;
}
前端实现代码
<template>
<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="tips success" v-if="isPassing">{{successTip}}</div> -->
<div class="tips danger" v-if="isPassing">{{ failTip }}</div>
<!-- 滑动条 -->
<div
class="slider"
:class="{
'verify-active': verifyActive,
'verify-success': verifySuccess,
'verify-fail': verifyFail,
}"
>
<!--滑动条提示文字-->
<div
class="slider-hint"
style="width: 335px; height: 40px; font-size: 14px"
>
{{ sliderHint }}
</div>
<!--滑块-->
<div class="slider-box" :style="{ width: sliderBoxWidth }">
<!-- 按钮 -->
<div
class="slider-button"
id="slider-button"
:style="{ left: sliderButtonLeft }"
>
<!-- 按钮图标 -->
<div class="el-icon-d-arrow-right" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, getCurrentInstance, ref, Ref, defineEmits } from "vue";
import { getCaptcha } from "@/api";
const { proxy }: any = getCurrentInstance();
const emit = defineEmits(["success", "fail"]);
function sum(x, y) {
return x + y;
}
function square(x) {
return x * x;
}
const props = defineProps({
// 阻塞块长度
blockLength: {
type: Number,
default: () => 42,
},
// 阻塞块弧度
blockRadius: {
type: Number,
default: () => 10,
},
// 画布宽度
canvasWidth: {
type: Number,
default: () => 355,
},
// 画布高度
canvasHeight: {
type: Number,
default: () => 188,
},
// 滑块操作提示
sliderHint: {
type: String,
default: () => "请按住滑块拖动",
},
// 可允许的误差范围小;为1时,则表示滑块要与凹槽完全重叠,才能验证成功。默认值为5,若为 -1 则不进行机器判断
accuracy: {
type: Number,
default: () => 3,
},
// 图片资源数组
imageList: {
type: Array,
default: () => [],
},
failTip: {
type: String,
default: () => "验证未通过,拖动滑块将悬浮图像正确合并",
},
});
// 前端校验
const isFrontCheck = ref(false);
// 校验进行状态
const verifyActive = ref(false);
// 校验成功状态
const verifySuccess = ref(false);
// 校验失败状态
const verifyFail = ref(false);
// 阻塞块对象
let blockObj: any = null;
// 图片画布对象
let canvasCtx: any = null;
// 阻塞块画布对象
let blockCtx: any = null;
// 阻塞块宽度
const blockWidth = props.blockLength * 2;
// 阻塞块的横轴坐标
let blockX: any = undefined;
// 阻塞块的纵轴坐标
let blockY: any = undefined;
// 图片对象
let image: any = undefined;
// 移动的X轴坐标
let originX: any = undefined;
// 移动的Y轴做坐标
let originY: any = undefined;
// 拖动距离数组
const dragDistanceList: any[] = [];
// 滑块箱拖动宽度
const sliderBoxWidth: any = ref(0);
// 滑块按钮距离左侧起点位置
const sliderButtonLeft: any = ref(0);
// 鼠标按下状态
let isMouseDown: boolean = false;
// 图片加载提示,防止图片没加载完就开始验证
const isLoading = ref(true);
// 时间戳,计算滑动时长
let timestamp: any = null;
// 成功提示
const successHint = ref("");
// 随机字符串
let ticket: any = undefined;
// 是否显示状态文字
let isPassing = ref(false);
const block: any = ref();
const canvas: any = ref();
onMounted(() => {
init();
});
/* 初始化 */
const init = () => {
initDom();
bindEvents();
};
/* 初始化DOM对象 */
const initDom = () => {
blockObj = block.value;
if (isFrontCheck.value) {
canvasCtx = canvas.getContext("2d");
blockCtx = blockObj.getContext("2d");
initImage();
} else {
getCaptchaApi();
}
};
/* 后台获取验证码 */
const getCaptchaApi = async () => {
// 取后端默认值
const data = {
canvasWidth: props.canvasWidth,
canvasHeight: props.canvasHeight,
blockWidth: blockWidth / 1.5,
blockHeight: blockWidth / 1.5,
blockRadius: props.blockRadius,
place: 1,
};
// 获取后台滑块验证码
await getCaptcha(data)
.then((response: any) => {
const data = response.data;
ticket = data.ticket;
block.value.src = data.blockSrc;
block.value.style.top = data.blockY + "px";
canvas.value.src = data.canvasSrc;
})
.finally(() => {
isLoading.value = false;
});
};
/* 前端获取验证码 */
const initImage = () => {
const newImage = createImage(() => {
drawBlock();
canvasCtx.drawImage(image, 0, 0, props.canvasWidth, props.canvasHeight);
blockCtx.drawImage(image, 0, 0, props.canvasWidth, props.canvasHeight);
// 将抠图防止最左边位置
let yAxle = blockY - props.blockRadius * 2;
let ImageData = blockCtx.getImageData(
blockX,
yAxle,
blockWidth,
blockWidth
);
blockObj.width = blockWidth;
blockCtx.putImageData(ImageData, 0, yAxle);
// 图片加载完关闭遮蔽罩
isLoading.value = false;
// 前端校验设置特殊值
ticket = "loyer";
});
image = newImage;
};
/* 创建image对象 */
const createImage = (onload) => {
const image: any = document.createElement("img");
image.crossOrigin = "Anonymous";
image.onload = onload;
image.onerror = () => {
image.src = new URL(
"../../../assets/images/bgImg.jpg",
import.meta.url
).href;
};
image.src = getImageSrc();
return image;
};
/* 获取imgSrc */
const getImageSrc = () => {
const len = props.imageList.length;
return props.imageList[getNonceByRange(0, len)];
};
/* 根据指定范围获取随机数 */
const getNonceByRange = (start, end) => {
return Math.round(Math.random() * (end - start) + start);
};
/* 绘制阻塞块 */
const drawBlock = () => {
blockX = getNonceByRange(
blockWidth + 10,
props.canvasWidth - (blockWidth + 10)
);
blockY = getNonceByRange(
10 + props.blockRadius * 2,
props.canvasHeight - (blockWidth + 10)
);
draw(canvasCtx, "fill");
draw(blockCtx, "clip");
};
/* 绘制事件 */
const draw = (ctx, operation) => {
const PI = Math.PI;
let x = blockX;
let y = blockY;
let l = props.blockLength;
let r = props.blockRadius;
// 绘制
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";
};
/* 事件绑定 */
const bindEvents = () => {
// 监听鼠标按下事件
document
.getElementById("slider-button")
?.addEventListener("mousedown", (event) => {
startEvent(event.clientX, event.clientY);
});
// 监听鼠标移动事件
document.addEventListener("mousemove", (event) => {
moveEvent(event.clientX, event.clientY);
});
// 监听鼠标离开事件
document.addEventListener("mouseup", (event) => {
endEvent(event.clientX);
});
// 监听触摸开始事件
document
.getElementById("slider-button")
?.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);
});
};
/* 校验图片是否存在 */
const checkImgSrc = () => {
if (isFrontCheck.value) {
return true;
}
return !!canvas.value.src;
};
/* 滑动开始事件 */
const startEvent = (originXx, originYy) => {
isPassing.value = false;
if (!checkImgSrc() || isLoading.value || verifySuccess.value) {
return;
}
originX = originXx;
originY = originYy;
isMouseDown = true;
timestamp = +new Date();
};
/* 滑动事件 */
const moveEvent = (originXx, originYy) => {
if (!isMouseDown) {
return false;
}
const moveX = originXx - originX;
const moveY = originYy - originY;
if (moveX < 0 || moveX + 40 >= props.canvasWidth) {
return false;
}
sliderButtonLeft.value = moveX + "px";
let blockLeft =
((props.canvasWidth - 40 - 20) / (props.canvasWidth - 40)) * moveX;
blockObj.style.left = blockLeft + "px";
verifyActive.value = true;
sliderBoxWidth.value = moveX + "px";
dragDistanceList.push(moveY);
};
/* 滑动结束事件 */
const endEvent = (originXx) => {
if (!isMouseDown) {
return false;
}
isMouseDown = false;
if (originXx === originX) {
return false;
}
// 开始校验
isLoading.value = true;
// 校验结束
verifyActive.value = false;
// 滑动时长
timestamp = +new Date() - timestamp;
// 移动距离
const moveLength = parseInt(blockObj.style.left);
// 限制操作时长10S,超出判断失败
if (timestamp > 10000) {
verifyFailEvent();
}
// 是否前端校验
else if (isFrontCheck.value) {
const accuracyData =
props.accuracy <= 1 ? 1 : props.accuracy > 10 ? 10 : props.accuracy; // 容错精度值
const spliced = Math.abs(moveLength - blockX) <= accuracyData; // 判断是否重合
console.log(spliced, "shifou chonghe");
if (!spliced) {
verifyFailEvent();
} else {
// 设置特殊值,后台特殊处理,直接验证通过
emit("success", {
ticket: ticket,
moveLength: moveLength,
});
}
} else {
emit("success", { ticket: ticket, moveLength: moveLength });
}
};
/* 图灵测试 */
const turingTest = () => {
const arr = 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; // 判断是否人为操作
};
/* 校验成功 */
const verifySuccessEvent = () => {
isLoading.value = false;
verifySuccess.value = true;
const elapsedTime = Number((timestamp / 1000).toFixed(1));
if (elapsedTime < 1) {
successHint.value = `仅仅${elapsedTime}S,你的速度快如闪电`;
} else if (elapsedTime < 2) {
successHint.value = `只用了${elapsedTime}S,这速度简直完美`;
} else {
successHint.value = `耗时${elapsedTime}S,争取下次再快一点`;
}
};
/* 校验失败 */
const verifyFailEvent = (msg?: string) => {
verifyFail.value = true;
emit("fail", msg);
refresh();
};
/* 刷新图片验证码 */
const refresh = () => {
// 延迟class的删除,等待动画结束
setTimeout(() => {
verifyFail.value = false;
}, 500);
isLoading.value = true;
verifyActive.value = false;
verifySuccess.value = false;
blockObj.style.left = 0;
sliderBoxWidth.value = 0;
sliderButtonLeft.value = 0;
if (isFrontCheck.value) {
// 刷新画布
canvasCtx.clearRect(0, 0, props.canvasWidth, props.canvasHeight);
blockCtx.clearRect(0, 0, props.canvasWidth, props.canvasHeight);
blockObj.width = props.canvasWidth;
// 刷新图片
image.src = getImageSrc();
} else {
getCaptchaApi();
}
};
defineExpose({
verifyFailEvent,
verifySuccessEvent,
isPassing,
refresh,
});
</script>
<style scoped lang="scss">
::v-deep .slider-box #slider-button .el-icon-d-arrow-right {
color: #e8e8e8;
}
</style>
<style scoped lang="scss">
.slide-verify {
position: relative;
}
/* 图片加载样式 */
.img-loading {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 999;
border-radius: 5px;
background-position: center center;
background-repeat: no-repeat;
background-size: 100px;
background-color: #737c8e;
animation: loading 1.5s infinite;
background-image: url(../../../assets/svg/loading.svg);
}
@keyframes loading {
0% {
opacity: 0.7;
}
100% {
opacity: 9;
}
}
/* 认证成功后的文字提示 */
.success-hint {
position: absolute;
top: 0;
right: 0;
left: 0;
z-index: 999;
display: flex;
justify-content: center;
align-items: center;
font-size: large;
color: #2cd000;
background: rgb(255 255 255 / 80%);
}
/* 刷新按钮 */
.refresh-icon {
position: absolute;
top: 0;
right: 0;
width: 35px;
height: 35px;
background: url("../../../assets/images/light.png") 0 -432px;
background-size: 35px 470px;
cursor: pointer;
}
/* 验证图片 */
/* .slide-canvas {
border-radius: px;
} */
/* 阻塞块 */
.slide-block {
position: absolute;
top: 0;
left: 0;
}
/* 校验失败时的阻塞块样式 */
.slide-block.verify-fail {
transition: left 0.5s linear;
}
/* 滑动条 */
.slider {
position: relative;
margin-top: 10px;
border: 1px solid #e4e7eb;
border-radius: 5px;
width: 100%;
height: 40px;
text-align: center;
color: #45494c;
background-color: #e8e8e8;
line-height: 40px;
}
/* 滑动盒子 */
.slider-box {
position: absolute;
top: 0;
left: 0;
border: 0 solid #1991fa;
border-radius: 5px;
height: 40px;
background: #d1e9fe;
}
/* 滑动按钮 */
.slider-button {
position: absolute;
top: 0;
left: 0;
border-radius: 5px;
width: 40px;
height: 40px;
background: #fff;
box-shadow: 0 0 3px rgb(0 0 0 / 30%);
transition: background 0.2s linear;
cursor: move;
}
/* 鼠标悬浮时的按钮样式 */
.slider-button:hover {
background: #3065ea;
}
.slider-button {
background: #3065ea;
}
/* 鼠标悬浮时的按钮图标样式 */
.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/light.png") 0 -26px;
background-size: 35px 470px;
}
/* 校验时的按钮样式 */
.verify-active .slider-button {
top: -1px;
border: 1px solid #1991fa;
height: 38px;
}
/* 校验时的滑动箱样式 */
.verify-active .slider-box {
border-width: 1px;
height: 38px;
}
/* 校验成功时的滑动箱样式 */
.verify-success .slider-box {
border: 1px solid #52ccba;
height: 38px;
background-color: #d2f4ef;
}
/* 校验成功时的按钮样式 */
.verify-success .slider-button {
top: -1px;
border: 1px solid #52ccba;
height: 38px;
background-color: #52ccba !important;
}
/* 校验成功时的按钮图标样式 */
.verify-success .slider-button-icon {
background-position: 0 0 !important;
}
/* 校验失败时的滑动箱样式 */
.verify-fail .slider-box {
border: 1px solid #f57a7a;
height: 38px;
background-color: #fce1e1;
transition: width 0.5s linear;
}
/* 校验失败时的按钮样式 */
.verify-fail .slider-button {
top: -1px;
border: 1px solid #f57a7a;
height: 38px;
background-color: #f57a7a !important;
transition: left 0.5s linear;
}
/* 校验失败时的按钮图标样式 */
.verify-fail .slider-button-icon {
top: 14px;
background-position: 0 -82px !important;
}
@keyframes slidetounlock {
0% {
background-position: -175px 0;
}
100% {
background-position: 175px 0;
}
}
.slider .slider-hint {
position: absolute;
top: 0;
width: 100%;
height: 0.61rem;
font-size: 0.16rem;
color: transparent;
background: -webkit-gradient(
linear,
left top,
right top,
color-stop(0, #333),
color-stop(0.4, #333),
color-stop(0.5, #fff),
color-stop(0.6, #333),
color-stop(1, #333)
);
background-clip: text;
user-select: none;
-webkit-text-fill-color: transparent;
text-size-adjust: none;
animation: slidetounlock 3s infinite;
}
.tips {
position: absolute;
bottom: 57px;
z-index: 200;
width: 100%;
height: 20px;
font-size: 12px;
text-align: center;
line-height: 20px;
}
.tips.success {
color: green;
background: rgb(255 255 255 / 60%);
}
.tips.danger {
color: yellow;
background: rgb(0 0 0 / 60%);
}
/* 校验状态下的提示文字隐藏 */
.verify-active .slider-hint,
.verify-success .slider-hint,
.verify-fail .slider-hint {
display: none;
}
</style>
<template>
<div class="login">
<div class="login-logo">
<img src="@/assets/images/welcome/LawTrust.png" alt="" />
<span>demo</span>
</div>
<div class="login-content">
<div class="login-left">
<div class="login-left-content">
<div class="login-left-title">
demo
</div>
</div>
<img
class="login-index"
src="@/assets/images/common/login-index2.png"
alt=""
/>
</div>
<div class="login-right">
<div class="login-right-box">
<div class="login-right-box-tabs" v-show="!isShowSliderVerify">
<div class="login-right-box-tabs-item" @click="active = 2">
<span
:style="{
fontWeight: active === 2 ? 500 : 400,
}"
>密码登录</span
>
<div
:style="{
backgroundColor: active === 2 ? '#2DC783' : '#fff',
}"
></div>
</div>
<div class="login-right-box-tabs-item" @click="active = 1">
<span :style="{ 'font-weight': active === 1 ? 500 : 400 }"
>验证码登录</span
>
<div
:style="{
backgroundColor: active === 1 ? '#2DC783' : '#fff',
}"
></div>
</div>
</div>
<div class="login-right-box-form" v-show="!isShowSliderVerify">
<div class="login-right-box-form-item" v-show="active === 1">
<input
v-model="phoneNum"
@focus="phoneNumTips = ''"
placeholder="请输入手机号"
class="login-right-box-form-item-input"
/>
<div v-show="phoneNumTips">{{ phoneNumTips }}</div>
</div>
<div class="login-right-box-form-item" v-show="active === 1">
<input
v-model="verificationCode"
@focus="verificationCodeTips = ''"
maxlength="8"
placeholder="请输入验证码"
class="login-right-box-form-item-input"
/>
<el-button
class="login-right-box-form-code"
:disabled="isCode"
size="small"
@click="getCodeButton()"
>{{
countDown === 60 ? "获取验证码" : countDown + "s"
}}</el-button
>
<div v-show="verificationCodeTips">
{{ verificationCodeTips }}
</div>
</div>
<div class="login-right-box-form-item" v-show="active === 2">
<input
v-model="userPhone"
@focus="userPhoneTips = ''"
placeholder="请输入手机号"
class="login-right-box-form-item-input"
/>
<div v-show="userPhoneTips">{{ userPhoneTips }}</div>
</div>
<div class="login-right-box-form-item" v-show="active === 2">
<input
v-model="password"
@focus="passwordTips = ''"
placeholder="请输入密码"
type="password"
class="login-right-box-form-item-input"
/>
<div v-show="passwordTips">{{ passwordTips }}</div>
</div>
<!-- <div class="login-right-box-form-item" v-show="active === 2">
<div style="display: flex;algin-item: center;padding-left: 0px;">
<input class="login-right-box-form-item-code" v-model="verification" @focus="verificationTips = ''" placeholder="请输入图形验证码" />
<div class="login-right-box-form-item-img">rysv</div>
</div>
<div v-if="verificationTips">{{verificationTips}}</div>
</div> -->
<!-- <el-checkbox v-model="weekend" size="large" class="login-check"
>一周内免密登录</el-checkbox
> -->
<el-checkbox v-model="agreeLogin" size="large" class="login-check">
阅读并同意<span @click.stop.prevent="to('user')"
>《用户协议》</span
>和<span @click.stop.prevent="to('privacy')">《隐私政策》</span>
</el-checkbox>
<el-button
@click="submitDebounce()"
class="login-button"
type="primary"
style="width: 100%"
>立即登录</el-button
>
<div class="login-apply">
还没有Law Trust账号
<span @click="apply">免费申请试用</span>
</div>
</div>
<div
class="login-right-box-form verifyClass"
v-if="isShowSliderVerify"
>
<slider-verify
ref="sliderVerify"
:canvasWidth="340"
:canvasHeight="200"
@success="onSuccess"
@fail="onFail"
@again="onAgain"
:failTip="failTip"
>
</slider-verify>
<el-button
@click="submitDebounce()"
type="primary"
class="login-button"
login-button
style="margin: 20px 0; width: 100%"
>立即登录</el-button
>
</div>
</div>
</div>
<!--滑块验证-->
<!-- <el-dialog title="请拖动滑块完成拼图" width="360px" :visible.sync="isShowSliderVerify" :close-on-click-modal="true" @closed="refresh" append-to-body>
<slider-verify ref="sliderVerify" @success="onSuccess" @fail="onFail" @again="onAgain"/>
</el-dialog> -->
</div>
<div class="login-footer">copyright © iLAW All Rights Reserved</div>
</div>
</template>
<script setup lang="ts">
import { getCurrentInstance, ref, nextTick, onMounted } from "vue";
import SliderVerify from "./component/SliderVerify.vue";
import { useRouter } from "vue-router";
import { useMainStore } from "@/store";
import {
loginAccount,
loginCode,
getVerificationCode,
loginSlider,
} from "@/api";
const router = useRouter();
const mainStore = useMainStore();
const { proxy }: any = getCurrentInstance();
const sliderVerify = ref(null) as unknown as any;
const failTip = ref(""); // 滑块的错误提示
const active = ref(2);
const phoneNum = ref("");
const verificationCode = ref("");
const userPhone = ref("");
const password = ref("");
const verification = ref("");
const phoneNumTips = ref("");
const verificationCodeTips = ref("");
const userPhoneTips = ref("");
const passwordTips = ref("");
const verificationTips = ref("");
const countDown = ref(60);
let timer: any = null;
const isCode = ref(false);
// 随机字符串
const ticket = "";
// 验证值
// 验证码图片
const captchaSrc = "";
let loginParam: {} = {};
// 登录状态
const isLoading = false;
// 是否显示滑块验证
const showSlider = ref(false);
const isShowSliderVerify = ref(false);
const weekend = ref(false);
const agreeLogin = ref(false);
/* 滑动验证成功 */
const onSuccess = (captcha: Object) => {
if (active.value === 1) {
getCode(captcha);
} else {
Object.assign(loginParam, captcha);
login();
}
};
const apply = () => {
window.open(`http://lawtrust.cn/#/home`, "_blank");
};
const to = (type: string) => {
//user privacy
window.open(`/document?type=${type}`, "_blank");
};
const login = async () => {
let res: any = null;
if (active.value === 1) res = await loginCode(loginParam);
if (active.value === 2) res = await loginAccount(loginParam);
if (res.code === 0) {
if (isShowSliderVerify.value) {
sliderVerify.value.verifySuccessEvent();
isShowSliderVerify.value = false;
}
localStorage.setItem("permissions", JSON.stringify(res.data.permissions));
localStorage.setItem("USER_ID", res.data.userId);
localStorage.setItem("USER_NAME", res.data.userName);
localStorage.setItem("isAdmin", res.data.isAdmin);
localStorage.setItem("TOKEN_KEY", res.data.token);
localStorage.setItem("headImg", res.data.headImg);
localStorage.setItem("headImgUrl", res.data.headImgUrl);
localStorage.setItem("USER_PHONE", res.data.phone);
mainStore.changeUserName(res.data.userName);
mainStore.changeHeadImg(res.data.headImg);
mainStore.changePermissions(res.data.permissions);
router.push({ name: "WelcomePage" });
// location.reload();
return;
}
showSlider.value = res.data;
if (isShowSliderVerify.value) {
// 账号密码登录 存在滑块
showSlider.value = true;
isShowSliderVerify.value = false;
}
if (res.msg === "ticket为空" && res.data) {
isShowSliderVerify.value = true;
return;
}
proxy.$message.error(res.msg);
};
/* 滑动验证失败 */
const onFail = (msg) => {
failTip.value = msg;
sliderVerify.value.isPassing = true;
// this.message('error', '验证失败,请控制拼图对齐缺口1111')
};
/* 滑动验证异常 */
const onAgain = () => {
proxy.$message("error", "滑动操作异常,请重试");
};
/* 刷新验证码 */
const refresh = () => {
sliderVerify.value.refresh();
};
/* 提示弹框 */
const message = (type, message) => {
proxy.$message({
showClose: true,
type: type,
message: message,
duration: 1500,
});
};
const VueDebounce = (time) => {
let timeout: any = null;
return function () {
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => {
submit();
}, time);
};
};
const submitDebounce = VueDebounce(500);
const getCode = async (captcha) => {
await getVerificationCode({
phoneNum: phoneNum.value,
type: "LOGIN_TYPE",
...captcha,
}).then((res: any) => {
isCode.value = false;
if (res.code === 0) {
timer = setInterval(() => {
if (countDown.value === 1) {
countDown.value = 60;
isCode.value = false;
clearInterval(timer);
} else {
countDown.value -= 1;
}
}, 1000);
isShowSliderVerify.value = false;
} else {
if (res.msg === "验证未通过,拖动滑块将悬浮图像正确合并") {
sliderVerify.value.verifyFailEvent(res.msg);
} else {
isShowSliderVerify.value = false;
}
proxy.$message.error(res.msg);
}
});
};
const getCodeButton = async () => {
if (countDown.value !== 60) return false;
if (phoneNum.value && /^1[3456789]\d{9}$/.test(phoneNum.value)) {
isShowSliderVerify.value = true;
isCode.value = true;
} else {
if (phoneNum.value && !/^1[3456789]\d{9}$/.test(phoneNum.value)) {
phoneNumTips.value = "请输入正确格式的手机号";
} else {
phoneNumTips.value = "请输入手机号";
}
}
};
const submit = () => {
let flag = true;
if (active.value === 1) {
if (!phoneNum.value) {
flag = false;
phoneNumTips.value = "请输入手机号";
return;
}
if (phoneNum.value && !/^1[3456789]\d{9}$/.test(phoneNum.value)) {
flag = false;
phoneNumTips.value = "请输入正确格式的手机号";
return;
}
if (!verificationCode.value) {
flag = false;
verificationCodeTips.value = "请输入验证码";
return;
}
if (!agreeLogin.value) {
proxy.$message.error("请勾选 阅读并同意《用户协议》和《隐私政策》");
flag = false;
return;
}
loginParam = {
phoneNum: phoneNum.value,
type: "LOGIN_TYPE",
verificationCode: verificationCode.value,
rememberMe: weekend.value,
};
login();
} else if (active.value === 2) {
if (!userPhone.value) {
flag = false;
userPhoneTips.value = "请输入手机号";
return;
}
if (!password.value) {
flag = false;
passwordTips.value = "请输入密码";
return;
}
if (!agreeLogin.value) {
proxy.$message.error("请勾选 阅读并同意《用户协议》和《隐私政策》");
flag = false;
return;
}
// if (!verification.value) {
// flag = false
// verificationTips.value = '请输入图形验证码'
// }
loginParam = {
userPhone: userPhone.value,
password: password.value,
rememberMe: weekend.value,
};
// 判断次数小于2 则发送请求,否则
if (!showSlider.value) {
login();
return;
}
if (flag) {
isShowSliderVerify.value = true;
}
}
};
onMounted(async () => {
const res: any = await loginSlider();
showSlider.value = res.data;
});
</script>
<style scoped lang="scss">
.login {
height: 100%;
background: linear-gradient(180deg, #f6f4f2 0%, #f6f4f2 101.66%);
min-width: 1280px;
position: relative;
min-height: 660px;
.login-logo {
position: absolute;
top: 5%;
left: 6%;
display: flex;
flex-direction: column;
align-items: center;
img {
margin: 0 18px 8px 0;
width: 148px;
height: 26px;
}
span {
font-size: 13px;
font-family: "MiSans";
font-weight: 500;
letter-spacing: 0.32em;
color: #a4a8aa;
font-style: normal;
line-height: 20px;
}
}
.login-content {
display: flex;
justify-content: space-between;
align-items: center;
height: 100%;
}
.login-left {
position: relative;
display: flex;
justify-content: center;
align-items: center;
padding-top: 133px;
width: 50%;
min-width: 660px;
height: 100%;
flex-direction: column;
.login-left-content {
display: flex;
justify-content: space-between;
padding-left: 40px;
width: 660px;
flex-direction: column;
.login-left-title {
margin-bottom: 39px;
font-size: 28px;
font-family: "PingFang SC";
font-weight: 500;
color: #263b49;
font-style: normal;
line-height: 24px;
}
.login-left-tips {
font-size: 24px;
font-family: "PingFang SC";
font-weight: 400;
color: #a4a8ab;
font-style: normal;
line-height: 24px;
}
}
.login-index {
margin: 56px 0 70px;
width: 577px;
height: 236px;
}
}
.login-right {
position: relative;
width: 50%;
height: 100%;
.login-right-box {
position: absolute;
top: 50%;
left: 50%;
border-radius: 6px;
padding: 45px 80px 41px;
width: 500px;
height: 430px;
background: #fff;
box-shadow: 0 0 12px 9px rgb(39 111 44 / 4%);
transform: translate(-50%, -50%);
box-sizing: border-box;
.login-right-box-tabs {
display: flex;
align-items: center;
margin: 0 0 30px;
.login-right-box-tabs-item {
margin-right: 32px;
text-align: center;
cursor: pointer;
span {
font-size: 20px;
font-family: "PingFang SC";
font-weight: 400;
text-align: center;
color: #263b49;
font-style: normal;
line-height: 28px;
letter-spacing: 1.53846px;
}
div {
margin-top: 7px;
border-radius: 3px;
height: 2px;
background: #fff;
}
}
}
.login-right-box-form {
.login-right-box-form-item {
position: relative;
margin-bottom: 24px;
cursor: pointer;
.login-right-box-form-item-input {
border: 1px solid #c7c9cb;
border-radius: 4px;
padding: 0 24px;
width: 100%;
height: 40px;
background: #fff;
outline: none;
box-shadow: 0 2px 4px rgb(0 0 0 / 3%), 0 1px 2px rgb(0 0 0 / 3%);
box-sizing: border-box;
line-height: 40px;
}
> input:-webkit-autofill {
box-shadow: 0 0 0 400px #fff inset;
}
> div {
padding-top: 6px;
font-size: 12px;
color: rgb(245 108 108);
line-height: 1;
position: absolute;
}
.login-right-box-form-code {
position: absolute;
top: 8px;
right: 10px;
border: 0;
border-left: 1px solid #d8d8d8;
border-radius: 0 !important;
padding-left: 20px;
font-size: 14px;
font-family: "PingFang SC";
font-weight: 400;
color: #000;
background: #fff;
// transform: translateY(-50%);
}
.login-right-box-form-item-code {
margin-right: 10px;
border: none;
border-radius: 8px;
padding: 0 24px;
height: 0.64rem;
background: #f7f7f7;
outline: none;
flex: 1;
box-sizing: border-box;
line-height: 0.64rem;
}
.login-right-box-form-item-img {
display: inline-block;
border-radius: 8px;
width: 101px;
height: 48px;
background: #fafafa;
flex-shrink: 0;
box-sizing: border-box;
line-height: 48px;
}
}
.login-check {
margin-bottom: 5px;
height: 24px;
font-size: 12px;
font-family: "PingFang SC";
font-weight: 400;
text-align: center;
font-style: normal;
line-height: 17px;
span {
color: #2dc783;
cursor: pointer;
}
}
::v-deep .el-checkbox__input.is-checked .el-checkbox__inner {
border: 1px solid #263b49;
border-radius: 3px;
background-color: #263b49;
}
::v-deep .el-checkbox.el-checkbox--large .el-checkbox__label {
font-size: 12px;
}
.login-check + .login-check {
margin: 0;
}
.login-button {
margin-top: 10px;
border-radius: 100px;
height: 40px;
background: #263b49;
border: 0;
span {
color: #ffff;
}
}
.login-apply {
margin-top: 30px;
font-size: 14px;
font-family: "PingFang SC";
font-weight: 400;
text-align: center;
color: #000;
font-style: normal;
line-height: 20px;
span {
color: #2dc783;
cursor: pointer;
}
}
}
}
}
.login-footer {
position: absolute;
bottom: 3%;
left: 50%;
font-size: 14px;
font-family: "PingFang SC";
font-weight: 400;
color: #000;
font-style: normal;
line-height: 22px;
transform: translateX(-50%);
}
}
.verifyClass {
margin-top: 20px;
}
.verifyClas .el-button {
margin-top: 10px !important;
}
</style>
bgImg.jpg
light.png
更多推荐
已为社区贡献1条内容
所有评论(0)