由于最近想做一个滑块验证码,苦于后端没有精美好看的样式,纯前端的验证码倒是挺好看的,但是不安全啊。找了很久一直没有找到想要的效果。无奈只能自己尝试着来干了。
网络上很多java生成拼图验证码的代码。但是都不太灵活,下面我们来看下。
这里要感谢作者javaLuo[^1]开源的vue-puzzle-vcode,我们的前端样式基本上基于该开源代码来修改。
<dependency>
<groupId>com.jhlabsgroupId>
<artifactId>filtersartifactId>
<version>2.0.235version>
dependency>
<dependency>
<groupId>cn.hutoolgroupId>
<artifactId>hutool-allartifactId>
<version>5.4.1version>
dependency>
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
<optional>trueoptional>
dependency>
主要技术点
1.使用GeneralPath描绘选区,这里区别其他文章方式。
2.使用jhlabs滤镜库来做滑块的阴影,主图的内阴影,做出立体的效果来
新增了描边功能
import cn.hutool.core.img.ImgUtil;
import cn.hutool.core.io.resource.Resource;
import cn.hutool.core.util.NumberUtil;
import com.jhlabs.image.ImageUtils;
import com.jhlabs.image.InvertAlphaFilter;
import com.jhlabs.image.ShadowFilter;
import lombok.Data;
import javax.imageio.stream.ImageInputStream;
import java.awt.*;
import java.awt.geom.Arc2D;
import java.awt.geom.GeneralPath;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.InputStream;
import java.util.Random;
/**
* 滑块验证码
*
* @author hexm
* @date 2020/10/23
*/
@Data
public class PuzzleCaptcha {
/** 默认宽度,用来计算阴影基本长度 */
private static final int DEFAULT_WIDTH = 280;
/** 随机数 */
private static final Random RANDOM = new Random();
/** 蒙版 */
private static Color color = new Color(255, 255, 255, 204);
/** alpha通道过滤器 */
private static InvertAlphaFilter alphaFilter = new InvertAlphaFilter();
/** 边距 */
private static int margin = 10;
/** 生成图片的宽度 */
private int width = DEFAULT_WIDTH;
/** 生成图片高度 */
private int height = 150;
/** x轴的坐标,由算法决定 */
private int x;
/** y轴的坐标,由算法决定 */
private int y;
/** 拼图长宽 */
private int vwh = 10 * 3;
/** 原图 */
private Image image;
/** 大图 */
private Image artwork;
/** 小图 */
private Image vacancy;
/** 是否注重速度 */
private boolean isFast = false;
/** 小图描边颜色 */
private Color vacancyBorderColor;
/** 小图描边线条的宽度 */
private float vacancyBorderWidth = 2.5f;
/** 主图描边的颜色 */
private Color artworkBorderColor;
/** 主图描边线条的宽度 */
private float artworkBorderWidth = 5f;
/**
* 最高放大倍数,合理的放大倍数可以使图像平滑且提高渲染速度
* 当isFast为false时,此属性生效
* 放大倍数越高,生成的图像越平滑,受原始图片大小的影响。
*/
private double maxRatio = 2;
/**
* 画质
*
* @see Image#SCALE_DEFAULT
* @see Image#SCALE_FAST
* @see Image#SCALE_SMOOTH
* @see Image#SCALE_REPLICATE
* @see Image#SCALE_AREA_AVERAGING
*/
private int imageQuality = Image.SCALE_SMOOTH;
/**
* 从文件中读取图片
*
* @param file
*/
public PuzzleCaptcha(File file) {
image = ImgUtil.read(file);
}
/**
* 从文件中读取图片,请使用绝对路径,使用相对路径会相对于ClassPath
*
* @param imageFilePath
*/
public PuzzleCaptcha(String imageFilePath) {
image = ImgUtil.read(imageFilePath);
}
/**
* 从{@link Resource}中读取图片
*
* @param resource
*/
public PuzzleCaptcha(Resource resource) {
image = ImgUtil.read(resource);
}
/**
* 从流中读取图片
*
* @param imageStream
*/
public PuzzleCaptcha(InputStream imageStream) {
image = ImgUtil.read(imageStream);
}
/**
* 从图片流中读取图片
*
* @param imageStream
*/
public PuzzleCaptcha(ImageInputStream imageStream) {
image = ImgUtil.read(imageStream);
}
/**
* 加载图片
*
* @param image
*/
public PuzzleCaptcha(Image image) {
this.image = image;
}
/**
* 加载图片
*
* @param bytes
*/
public PuzzleCaptcha(byte[] bytes) {
this.image = ImgUtil.read(new ByteArrayInputStream(bytes));
}
/**
* 生成随机x、y坐标
*/
private void init() {
if (x == 0 || y == 0) {
this.x = random(vwh, this.width - vwh - margin);
this.y = random(margin, this.height - vwh - margin);
}
}
/**
* 执行
*/
public void run() {
init();
// 缩略图
Image thumbnail;
GeneralPath path;
int realW = image.getWidth(null);
int realH = image.getHeight(null);
int w = realW, h = realH;
double wScale = 1, hScale = 1;
// 如果原始图片比执行的图片还小,则先拉伸再裁剪
boolean isFast = this.isFast || w < this.width || h < this.height;
if (isFast) {
// 缩放,使用平滑模式
thumbnail = image.getScaledInstance(width, height, imageQuality);
path = paintBrick(1, 1);
w = this.width;
h = this.height;
} else {
// 缩小到一定的宽高,保证裁剪的圆润
boolean flag = false;
if (realW > width * maxRatio) {
// 不超过最大倍数且不超过原始图片的宽
w = Math.min((int) (width * maxRatio), realW);
flag = true;
}
if (realH > height * maxRatio) {
h = Math.min((int) (height * maxRatio), realH);
flag = true;
}
if (flag) {
// 若放大倍数生效,则缩小图片至最高放大倍数,再进行裁剪
thumbnail = image.getScaledInstance(w, h, imageQuality);
} else {
thumbnail = image;
}
hScale = NumberUtil.div(h, height);
wScale = NumberUtil.div(w, width);
path = paintBrick(wScale, hScale);
}
// 创建阴影过滤器
float radius = 5 * ((float) w / DEFAULT_WIDTH) * (float) wScale;
int left = 1;
ShadowFilter shadowFilter = new ShadowFilter(radius, 2 * (float) wScale, -1 * (float) hScale, 0.8f);
// 创建空白的图片
BufferedImage artwork = translucent(new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB));
BufferedImage localVacancy = translucent(new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB));
// 画小图
Graphics2D vg = localVacancy.createGraphics();
// 抗锯齿
vg.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
// 设置画图路径范围
vg.setClip(path);
// 将区域中的图像画到小图中
vg.drawImage(thumbnail, null, null);
//描边
if (vacancyBorderColor != null) {
vg.setColor(vacancyBorderColor);
vg.setStroke(new BasicStroke(vacancyBorderWidth));
vg.draw(path);
}
// 释放图像
vg.dispose();
// 画大图
// 创建画笔
Graphics2D g = artwork.createGraphics();
// 抗锯齿
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
// 画上图片
g.drawImage(thumbnail, null, null);
// 设置画图路径范围
g.setClip(path);
// 填充缺口透明度 颜色混合,不透明在上
g.setComposite(AlphaComposite.SrcAtop);
// 填充一层白色的透明蒙版,透明度越高,白色越深 alpha:0-255
g.setColor(color);
g.fill(path);
//描边
if (artworkBorderColor != null) {
g.setColor(artworkBorderColor);
g.setStroke(new BasicStroke(artworkBorderWidth));
g.draw(path);
}
// 画上基于小图的内阴影,先反转alpha通道,然后创建阴影
g.drawImage(shadowFilter.filter(alphaFilter.filter(localVacancy, null), null), null, null);
// 释放图像
g.dispose();
// 裁剪掉多余的透明背景
localVacancy = ImageUtils.getSubimage(localVacancy, (int) (x * wScale - left), 0, (int) Math.ceil(path.getBounds().getWidth() + radius) + left, h);
if (isFast) {
// 添加阴影
this.vacancy = shadowFilter.filter(localVacancy, null);
this.artwork = artwork;
} else {
// 小图添加阴影
localVacancy = shadowFilter.filter(localVacancy, null);
// 大图缩放
this.artwork = artwork.getScaledInstance(width, height, imageQuality);
// 缩放时,需要加上阴影的宽度,再除以放大比例
this.vacancy = localVacancy.getScaledInstance((int) ((path.getBounds().getWidth() + radius) / wScale), height, imageQuality);
}
}
/**
* 绘制拼图块的路径
*
* @param xScale x轴放大比例
* @param yScale y轴放大比例
* @return
*/
private GeneralPath paintBrick(double xScale, double yScale) {
double x = this.x * xScale;
double y = this.y * yScale;
// 直线移动的基础距离
double hMoveL = vwh / 3f * yScale;
double wMoveL = vwh / 3f * xScale;
GeneralPath path = new GeneralPath();
path.moveTo(x, y);
path.lineTo(x + wMoveL, y);
// 上面的圆弧正东方向0°,顺时针负数,逆时针正数
path.append(arc(x + wMoveL, y - hMoveL / 2, wMoveL, hMoveL, 180, -180), true);
path.lineTo(x + wMoveL * 3, y);
path.lineTo(x + wMoveL * 3, y + hMoveL);
// 右边的圆弧
path.append(arc(x + wMoveL * 2 + wMoveL / 2, y + hMoveL, wMoveL, hMoveL, 90, -180), true);
path.lineTo(x + wMoveL * 3, y + hMoveL * 3);
path.lineTo(x, y + hMoveL * 3);
path.lineTo(x, y + hMoveL * 2);
// 左边的内圆弧
path.append(arc(x - wMoveL / 2, y + hMoveL, wMoveL, hMoveL, -90, 180), true);
path.lineTo(x, y);
path.closePath();
return path;
}
/**
* 绘制圆形、圆弧或者是椭圆形
* 正东方向0°,顺时针负数,逆时针正数
*
* @param x 左上角的x坐标
* @param y 左上角的y坐标
* @param w 宽
* @param h 高
* @param start 开始的角度
* @param extent 结束的角度
* @return
*/
private Arc2D arc(double x, double y, double w, double h, double start, double extent) {
return new Arc2D.Double(x, y, w, h, start, extent, Arc2D.OPEN);
}
/**
* 透明背景
*
* @param bufferedImage
* @return
*/
private BufferedImage translucent(BufferedImage bufferedImage) {
Graphics2D g = bufferedImage.createGraphics();
bufferedImage = g.getDeviceConfiguration().createCompatibleImage(bufferedImage.getWidth(), bufferedImage.getHeight(), Transparency.TRANSLUCENT);
g.dispose();
return bufferedImage;
}
/**
* 随机数
*
* @param min
* @param max
* @return
*/
private static int random(int min, int max) {
return RANDOM.ints(min, max + 1).findFirst().getAsInt();
}
}
注释很详细了,就不多描述了。
技术要点
1.生成验证码图片,将宽度、当前时间戳与偏移量x存入缓存redis中。
2.验证前端的宽度与原始图片的宽度比例。比较偏移量x的时候需要乘以这个比例,否则会出现后端图片大,前端图片小,偏移量和后端差距过大不通过。
3.与生成照片的时间戳比较,如果小于500毫秒,认为是非人操作^_^,我们让它失败重来.
4.最后将验证结果存入redis中。删除本地的验证码缓存信息,防止二次使用。
5.登陆的时候,从redis中获取验证结果,如果结果成功,删除验证结果,进入登陆流程。
import cn.hutool.core.util.NumberUtil;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.awt.*;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
/**
* 验证码
*
* @author hexm
* @date 2020/10/27 10:58
*/
@RestController
@RequestMapping("/captcha")
public class CaptchaController {
private static final int X_OFFSET = 8;
private static final int SPEED = 500;
@Autowired
private Cache<Object> cache;
@ApiOperation("获取验证码")
@GetMapping(value = "/")
public CaptchaVo captcha() {
// 删除上次验证结果
cache.remove(CacheConstant.CAPTCHA_RESULT + ThreadContextHolder.getSession().getId());
PuzzleCaptcha puzzleCaptcha = new PuzzleCaptcha(CaptchaUtil.randomImage());
puzzleCaptcha.setImageQuality(Image.SCALE_AREA_AVERAGING);
puzzleCaptcha.run();
Map<String, Object> cacheMap = new HashMap<>();
CaptchaVo captchaVo = new CaptchaVo();
captchaVo.setImage1(ImageConvertUtil.toDataUri(puzzleCaptcha.getArtwork(), "png"));
captchaVo.setImage2(ImageConvertUtil.toDataUri(puzzleCaptcha.getVacancy(), "png"));
// 偏移量
cacheMap.put("x", puzzleCaptcha.getX());
cacheMap.put("time", System.currentTimeMillis());
cacheMap.put("width", puzzleCaptcha.getWidth());
cache.put(CacheConstant.CAPTCHA + ThreadContextHolder.getSession().getId(), cacheMap, 5 * 60);
return captchaVo;
}
@ApiOperation("验证码验证")
@PostMapping(value = "/verify")
public CaptchaResult verify(@RequestBody Map<String, Object> map) {
CaptchaResult result = new CaptchaResult();
result.setSuccess(false);
String key = CacheConstant.CAPTCHA + ThreadContextHolder.getSession().getId();
// 偏移量
Integer vx = StrUtil.toInt(map.get("x"));
// 宽度
Integer width = StrUtil.toInt(map.get("width"), 1);
//缓存
Map<String, Object> cacheMap = cache.get(key);
if (cacheMap == null) {
result.setMessage(ServiceErrorCode.E1008.desc());
return result;
}
Integer x = StrUtil.toInt(cacheMap.get("x"));
Integer realWidth = StrUtil.toInt(cacheMap.get("width"));
Long time = StrUtil.toLong(cacheMap.get("time"));
// 验证速度
long s = System.currentTimeMillis() - time;
// 查看前端的缩放比例
double ratio = NumberUtil.div(realWidth, width).doubleValue();
if (x == null || vx == null) {
result.setMessage(ServiceErrorCode.E1008.desc());
cache.remove(key);
return result;
} else if (Math.abs(x - (vx * ratio)) > X_OFFSET * ratio || s < SPEED) {
result.setMessage(ServiceErrorCode.E1009.desc());
cache.remove(key);
return result;
}
result.setSuccess(true);
cache.remove(key);
cache.put(CacheConstant.CAPTCHA_RESULT + ThreadContextHolder.getSession().getId(), result, 5 * 60);
return result;
}
}
图片转base64 ImageConvertUtil
这里没有使用hutool的ImgUtil.toBase64是因为该工具类png格式的图片透明背景会变成黑色。希望更新版本后会修复这个bug。
import com.jhlabs.image.ImageUtils;
import javax.imageio.ImageIO;
import java.awt.*;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Base64;
/**
* @author hexm
* @date 2020/10/27 15:37
*/
public class ImageConvertUtil {
/**
* 将image对象转为base64字符串
*
* @param image
* @return
*/
public static String toBase64(Image image, String format) {
return Base64.getEncoder().encodeToString(toBytes(image, format));
}
/**
* 将image对象转为前端img标签识别的base64字符串
*
* @param image
* @param format
* @return
*/
public static String toDataUri(Image image, String format) {
return String.format("data:image/%s;base64,%s", format, toBase64(image, format));
}
/**
* 将image对象转为字节
*
* @param image
* @param format
* @return
*/
public static byte[] toBytes(Image image, String format) {
ByteArrayOutputStream stream = new ByteArrayOutputStream();
try {
ImageIO.write(ImageUtils.convertImageToARGB(image), format, stream);
} catch (IOException e) {
e.printStackTrace();
}
return stream.toByteArray();
}
}
修改的地方不会很多,大部分删减后移动到了后端
<template>
<div
:id="id"
:class="['vue-puzzle-vcode', { show_: show }]"
@mousedown="onCloseMouseDown"
@mouseup="onCloseMouseUp"
@touchstart="onCloseMouseDown"
@touchend="onCloseMouseUp"
>
<div class="vue-auth-box_" :style="{'border-radius':borderRadius+'px'}" @mousedown.stop @touchstart.stop>
<div class="auth-body_" :style="`height: ${canvasHeight}px`">
<img ref="img1" :src="image1" alt="" :width="canvasWidth" :height="canvasHeight">
<img
ref="img2"
:src="image2"
class="auth-canvas2_"
:height="canvasHeight"
:style="`transform:translateX(${styleWidth - sliderBaseSize}px)`"
alt=""
>
<div :class="['loading-box_', { hide_: !loading }]">
<div class="loading-gif_">
<span />
<span />
<span />
<span />
<span />
div>
div>
<div :class="['info-box_', { show: infoBoxShow }, { fail: infoBoxFail }]">
{{ infoText }}
div>
<div
:class="['flash_', { show: isSuccess }]"
:style="
`transform: translateX(${
isSuccess
? `${canvasWidth + canvasHeight * 0.578}px`
: `-${canvasHeight * 0.578}px`
}) skew(-30deg, 0);`
"
/>
<img class="reset_" :src="resetSvg" @click="reset">
div>
<div class="auth-control_">
<div class="range-box" :style="`height:${sliderBaseSize}px`">
<div class="range-text">{{ sliderText }}div>
<div ref="range-slider" class="range-slider" :style="`width:${styleWidth}px`">
<div :class="['range-btn', { isDown: mouseDown }]" :style="`width:${sliderBaseSize}px`" @mousedown="onRangeMouseDown($event)" @touchstart="onRangeMouseDown($event)">
<div />
<div />
<div />
div>
div>
div>
div>
div>
div>
template>
<script>
import { captcha, captchaVerify } from '@/api/common/common'
const resetSvg = require('@/components/PuzzleCode/reset.png')
export default {
name: 'PuzzleCode',
/** 父级参数 **/
props: {
id: { type: String, default: undefined },
canvasWidth: { type: Number, default: 280 }, // 主canvas的宽
canvasHeight: { type: Number, default: 150 }, // 主canvas的高
// 是否出现,由父级控制
show: { type: Boolean, default: false },
sliderSize: { type: Number, default: 35 }, // 滑块的大小
successText: {
type: String,
default: '验证通过!'
},
failText: {
type: String,
default: '验证失败,请重试'
},
sliderText: {
type: String,
default: '拖动滑块完成拼图'
},
borderRadius: {
type: Number,
default: 10
}
},
/** 私有数据 **/
data() {
return {
image1: undefined, // 大图
image2: undefined, // 小图
mouseDown: false, // 鼠标是否在按钮上按下
startWidth: 50, // 鼠标点下去时父级的width
startX: 0, // 鼠标按下时的X
newX: 0, // 鼠标当前的偏移X
loading: true, // 是否正在加载中,主要是等图片onload
isCanSlide: false, // 是否可以拉动滑动条
error: false, // 图片加在失败会出现这个,提示用户手动刷新
infoBoxShow: false, // 提示信息是否出现
infoText: '', // 提示等信息
infoBoxFail: false, // 是否验证失败
timer1: null, // setTimout1
closeDown: false, // 为了解决Mac上的click BUG
isSuccess: false, // 验证成功
resetSvg,
isSubmit: false // 是否正在验证中
}
},
/** 计算属性 **/
computed: {
// styleWidth是底部用户操作的滑块的父级,就是轨道在鼠标的作用下应该具有的宽度
styleWidth() {
const w = this.startWidth + this.newX - this.startX
return w < this.sliderBaseSize
? this.sliderBaseSize
: w > this.canvasWidth
? this.canvasWidth
: w
},
// 处理一下sliderSize,弄成整数,以免计算有偏差
sliderBaseSize() {
return Math.max(
Math.min(
Math.round(this.sliderSize),
Math.round(this.canvasWidth * 0.5)
),
10
)
}
},
/** 监听 **/
watch: {
show(newV) {
// 每次出现都应该重新初始化
if (newV) {
document.body.classList.add('vue-puzzle-overflow')
this.reset()
} else {
document.body.classList.remove('vue-puzzle-overflow')
}
}
},
/** 生命周期 **/
mounted() {
document.body.appendChild(this.$el)
document.addEventListener('mousemove', this.onRangeMouseMove, false)
document.addEventListener('mouseup', this.onRangeMouseUp, false)
document.addEventListener('touchmove', this.onRangeMouseMove, {
passive: false
})
document.addEventListener('touchend', this.onRangeMouseUp, false)
if (this.show) {
document.body.classList.add('vue-puzzle-overflow')
this.reset()
}
},
beforeDestroy() {
clearTimeout(this.timer1)
document.body.removeChild(this.$el)
document.removeEventListener('mousemove', this.onRangeMouseMove, false)
document.removeEventListener('mouseup', this.onRangeMouseUp, false)
document.removeEventListener('touchmove', this.onRangeMouseMove, {
passive: false
})
document.removeEventListener('touchend', this.onRangeMouseUp, false)
},
/** 方法 **/
methods: {
// 关闭
onClose() {
if (!this.mouseDown) {
clearTimeout(this.timer1)
this.$emit('close')
}
},
onCloseMouseDown() {
this.closeDown = true
},
onCloseMouseUp() {
if (this.closeDown) {
this.onClose()
}
this.closeDown = false
},
// 鼠标按下准备拖动
onRangeMouseDown(e) {
if (this.isCanSlide) {
this.mouseDown = true
this.startWidth = this.$refs['range-slider'].clientWidth
this.newX = e.clientX || e.changedTouches[0].clientX
this.startX = e.clientX || e.changedTouches[0].clientX
}
},
// 鼠标移动
onRangeMouseMove(e) {
if (this.mouseDown) {
e.preventDefault()
this.newX = e.clientX || e.changedTouches[0].clientX
}
},
// 鼠标抬起
onRangeMouseUp() {
if (this.mouseDown) {
this.mouseDown = false
this.submit()
}
},
/**
* 开始进行
*/
init() {
this.loading = true
this.isCanSlide = false
captcha().then(res => {
this.image1 = res.image1
this.image2 = res.image2
this.loading = false
this.isCanSlide = true
this.startTime = new Date().getTime()
})
},
// 开始判定
submit() {
// 关闭拖动
this.isCanSlide = false
this.isSubmit = true
const x = this.newX - this.startX
this.loading = true
captchaVerify({ x, width: this.canvasWidth }).then(res => {
this.isSubmit = false
this.loading = false
if (res.success) {
// 成功
this.infoText = this.successText
this.infoBoxFail = false
this.infoBoxShow = true
this.isCanSlide = false
this.isSuccess = true
// 成功后准备关闭
clearTimeout(this.timer1)
this.timer1 = setTimeout(() => {
// 成功的回调
this.$emit('success', x)
}, 800)
} else {
// 失败
this.infoText = this.failText
this.infoBoxFail = true
this.infoBoxShow = true
this.isCanSlide = false
// 失败的回调
this.$emit('fail', x)
// 800ms后重置
clearTimeout(this.timer1)
this.timer1 = setTimeout(() => {
this.reset()
}, 800)
}
})
},
// 重置
reset() {
this.infoBoxFail = false
this.infoBoxShow = false
this.isCanSlide = true
this.isSuccess = false
this.startWidth = this.sliderBaseSize // 鼠标点下去时父级的width
this.startX = 0 // 鼠标按下时的X
this.newX = 0 // 鼠标当前的偏移X
this.init()
}
}
}
script>
<style lang="scss">
.vue-puzzle-vcode {
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
background-color: rgba(0, 0, 0, 0.3);
z-index: 999;
opacity: 0;
pointer-events: none;
transition: opacity 200ms;
&.show_ {
opacity: 1;
pointer-events: auto;
}
}
.vue-auth-box_ {
position: absolute;
top: 40%;
left: 50%;
transform: translate(-50%, -50%);
padding: 20px;
background: #fff;
user-select: none;
border-radius: 3px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
.auth-body_ {
position: relative;
overflow: hidden;
border-radius: 3px;
.loading-box_ {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
background-color: rgba(0, 0, 0, 0.8);
z-index: 20;
opacity: 1;
transition: opacity 200ms;
display: flex;
align-items: center;
justify-content: center;
&.hide_ {
opacity: 0;
pointer-events: none;
.loading-gif_ {
span {
animation-play-state: paused;
}
}
}
.loading-gif_ {
flex: none;
height: 5px;
line-height: 0;
@keyframes load {
0% {
opacity: 1;
transform: scale(1.3);
}
100% {
opacity: 0.2;
transform: scale(0.3);
}
}
span {
display: inline-block;
width: 5px;
height: 100%;
margin-left: 2px;
border-radius: 50%;
background-color: #888;
animation: load 1.04s ease infinite;
&:nth-child(1) {
margin-left: 0;
}
&:nth-child(2) {
animation-delay: 0.13s;
}
&:nth-child(3) {
animation-delay: 0.26s;
}
&:nth-child(4) {
animation-delay: 0.39s;
}
&:nth-child(5) {
animation-delay: 0.52s;
}
}
}
}
.info-box_ {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 24px;
line-height: 24px;
text-align: center;
overflow: hidden;
font-size: 13px;
background-color: #83ce3f;
opacity: 0;
transform: translateY(24px);
transition: all 200ms;
color: #fff;
z-index: 10;
&.show {
opacity: 0.95;
transform: translateY(0);
}
&.fail {
background-color: #ce594b;
}
}
.auth-canvas2_ {
position: absolute;
top: 0;
left: 0;
z-index: 2;
}
.auth-canvas3_ {
position: absolute;
top: 0;
left: 0;
opacity: 0;
transition: opacity 600ms;
z-index: 3;
&.show {
opacity: 1;
}
}
.flash_ {
position: absolute;
top: 0;
left: 0;
width: 30px;
height: 100%;
background-color: rgba(255, 255, 255, 0.1);
z-index: 3;
&.show {
transition: transform 600ms;
}
}
.reset_ {
position: absolute;
top: 2px;
right: 2px;
width: 35px;
height: auto;
z-index: 12;
cursor: pointer;
transition: transform 200ms;
transform: rotate(0deg);
&:hover {
transform: rotate(-90deg);
}
}
}
.auth-control_ {
.range-box {
position: relative;
width: 100%;
background-color: #eef1f8;
margin-top: 20px;
border-radius: 3px;
box-shadow: 0 0 8px rgba(240, 240, 240, 0.6) inset;
.range-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 14px;
color: #b7bcd1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
width: 100%;
}
.range-slider {
position: absolute;
height: 100%;
width: 50px;
background-color: rgba(106, 160, 255, 0.8);
border-radius: 3px;
.range-btn {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
right: 0;
width: 50px;
height: 100%;
background-color: #fff;
border-radius: 3px;
box-shadow: 0 0 4px #ccc;
cursor: pointer;
& > div {
width: 0;
height: 40%;
transition: all 200ms;
&:nth-child(2) {
margin: 0 4px;
}
border: solid 1px #6aa0ff;
}
&:hover,
&.isDown {
& > div:first-child {
border: solid 4px transparent;
height: 0;
border-right-color: #6aa0ff;
}
& > div:nth-child(2) {
border-width: 3px;
height: 0;
border-radius: 3px;
margin: 0 6px;
border-right-color: #6aa0ff;
}
& > div:nth-child(3) {
border: solid 4px transparent;
height: 0;
border-left-color: #6aa0ff;
}
}
}
}
}
}
}
.vue-puzzle-overflow {
overflow: hidden !important;
}
style>
api
/**
* 验证码
* @returns {*}
*/
export function captcha() {
return request({
url: `/captcha/`,
method: 'get'
})
}
/**
* 验证码验证
* @returns {*}
*/
export function captchaVerify(data) {
return request({
url: `/captcha/verify`,
method: 'post',
headers: { 'Content-Type': 'application/json' },
data
})
}
好了,代码基本已经结束,reset.png图片找不到的,可以到vue-puzzle-vcode里面下载,或者替换成其他图片也是可以的。
既然已经实现了png的拼图,我们来看一下gif动态拼图的可能性。
首先,gif格式不支持半透明的效果,也就是说小图的外阴影不能用了,会有白色的边,我们改成内阴影
看下效果,其实还行,就是处理速度有点慢。
总体的差别不多,使用多线程加速一下,否则多帧图像处理速度会比较慢。
下面是实现的代码,使用上没有太大的差别
import cn.hutool.core.img.gif.AnimatedGifEncoder;
import cn.hutool.core.img.gif.GifDecoder;
import cn.hutool.core.util.NumberUtil;
import com.jhlabs.image.ImageUtils;
import com.jhlabs.image.InvertAlphaFilter;
import com.jhlabs.image.ShadowFilter;
import lombok.Data;
import java.awt.*;
import java.awt.geom.Arc2D;
import java.awt.geom.GeneralPath;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.util.Random;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
/**
* gif滑块验证码
*
* @author hexm
* @date 2020/10/23
*/
@Data
public class PuzzleGifCaptcha {
/** 默认宽度,用来计算阴影基本长度 */
private static final int DEFAULT_WIDTH = 280;
/** 随机数 */
private static final Random RANDOM = new Random();
/** 蒙版 */
private static Color color = new Color(255, 255, 255, 204);
/** alpha通道过滤器 */
private static InvertAlphaFilter alphaFilter = new InvertAlphaFilter();
/** 边距 */
private static int margin = 10;
/** 生成图片的宽度 */
private int width = DEFAULT_WIDTH;
/** 生成图片高度 */
private int height = 150;
/** x轴的坐标,由算法决定 */
private int x;
/** y轴的坐标,由算法决定 */
private int y;
/** 拼图长宽 */
private int vwh = 10 * 3;
/** 原图 */
private InputStream image;
/** 大图 */
private ByteArrayOutputStream artwork;
/** 小图 */
private ByteArrayOutputStream vacancy;
/** 是否注重速度 */
private boolean isFast = false;
/** 小图描边颜色 */
private Color vacancyBorderColor;
/** 小图描边线条的宽度 */
private float vacancyBorderWidth = 2.5f;
/** 主图描边的颜色 */
private Color artworkBorderColor;
/** 主图描边线条的宽度 */
private float artworkBorderWidth = 5f;
/**
* 最高放大倍数,合理的放大倍数可以使图像平滑且提高渲染速度
* 当isFast为false时,此属性生效
* 放大倍数越高,生成的图像越平滑,受原始图片大小的影响。
*/
private double maxRatio = 2;
/**
* 画质
*
* @see Image#SCALE_DEFAULT
* @see Image#SCALE_FAST
* @see Image#SCALE_SMOOTH
* @see Image#SCALE_REPLICATE
* @see Image#SCALE_AREA_AVERAGING
*/
private int imageQuality = Image.SCALE_SMOOTH;
/** 开启多线程处理 */
private boolean multithreading = true;
/**
* 从流中读取图片
*
* @param is
*/
public PuzzleGifCaptcha(InputStream is) {
image = is;
}
/**
* 从文件中读取图片
*
* @param fileName
*/
public PuzzleGifCaptcha(String fileName) throws FileNotFoundException {
image = new FileInputStream(fileName);
}
/**
* 生成随机x、y坐标
*/
private void init() {
if (x == 0 || y == 0) {
this.x = random(vwh, this.width - vwh - margin);
this.y = random(margin, this.height - vwh - margin);
}
}
/**
* 执行
*/
public void run() throws IOException {
init();
GifDecoder decoder = new GifDecoder();
int status = decoder.read(image);
if (status != GifDecoder.STATUS_OK) {
throw new IOException("read image error!");
}
AnimatedGifEncoder e = new AnimatedGifEncoder();
AnimatedGifEncoder e2 = new AnimatedGifEncoder();
ByteArrayOutputStream b1 = new ByteArrayOutputStream();
ByteArrayOutputStream b2 = new ByteArrayOutputStream();
//保存的目标图片
e.start(b1);
e2.start(b2);
e.setRepeat(decoder.getLoopCount());
e2.setRepeat(decoder.getLoopCount());
e.setDelay(decoder.getDelay(0));
e2.setDelay(decoder.getDelay(0));
e2.setTransparent(Color.white);
if (multithreading) {
// 多线程
CompletableFuture<BufferedImage[]>[] futures = new CompletableFuture[decoder.getFrameCount()];
for (int i = 0; i < decoder.getFrameCount(); i++) {
int finalI = i;
futures[i] = CompletableFuture.supplyAsync(() -> {
BufferedImage image = decoder.getFrame(finalI);
//可以加入对图片的处理,比如缩放,压缩质量
return process(image);
});
}
CompletableFuture.allOf(futures).join();
for (CompletableFuture<BufferedImage[]> future : futures) {
try {
BufferedImage[] bufferedImages = future.get();
e.addFrame(bufferedImages[0]);
e2.addFrame(bufferedImages[1]);
} catch (InterruptedException | ExecutionException interruptedException) {
interruptedException.printStackTrace();
}
}
} else {
// 单线程
for (int i = 0; i < decoder.getFrameCount(); i++) {
BufferedImage image = decoder.getFrame(i);
//可以加入对图片的处理,比如缩放,压缩质量
BufferedImage[] bufferedImages = process(image);
e.addFrame(bufferedImages[0]);
e2.addFrame(bufferedImages[1]);
}
}
e.finish();
e2.finish();
this.artwork = b1;
this.vacancy = b2;
if (image != null) {
image.close();
}
}
private BufferedImage[] process(Image image) {
// 缩略图
Image thumbnail;
GeneralPath path;
int realW = image.getWidth(null);
int realH = image.getHeight(null);
int w = realW, h = realH;
double wScale = 1, hScale = 1;
// 如果原始图片比执行的图片还小,则先拉伸再裁剪
boolean isFast = this.isFast || w < this.width || h < this.height;
if (isFast) {
// 缩放,使用平滑模式
thumbnail = image.getScaledInstance(width, height, imageQuality);
path = paintBrick(1, 1);
w = this.width;
h = this.height;
} else {
// 缩小到一定的宽高,保证裁剪的圆润
boolean flag = false;
if (realW > width * maxRatio) {
// 不超过最大倍数且不超过原始图片的宽
w = Math.min((int) (width * maxRatio), realW);
flag = true;
}
if (realH > height * maxRatio) {
h = Math.min((int) (height * maxRatio), realH);
flag = true;
}
if (flag) {
// 若放大倍数生效,则缩小图片至最高放大倍数,再进行裁剪
thumbnail = image.getScaledInstance(w, h, imageQuality);
} else {
thumbnail = image;
}
hScale = NumberUtil.div(h, height);
wScale = NumberUtil.div(w, width);
path = paintBrick(wScale, hScale);
}
// 创建阴影过滤器
float radius = 5 * ((float) w / DEFAULT_WIDTH) * (float) wScale;
ShadowFilter shadowFilter = new ShadowFilter(radius, 2 * (float) wScale, -1 * (float) hScale, 0.8f);
// 创建空白的图片
BufferedImage artwork = translucent(new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB));
BufferedImage localVacancy = translucent(new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB));
// 画小图
Graphics2D vg = localVacancy.createGraphics();
// 抗锯齿
vg.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
// 设置画图路径范围
vg.setClip(path);
// 将区域中的图像画到小图中
vg.drawImage(thumbnail, null, null);
//描边
if (vacancyBorderColor != null) {
vg.setColor(vacancyBorderColor);
vg.setStroke(new BasicStroke(vacancyBorderWidth));
vg.draw(path);
}
// 画上基于小图的内阴影,先反转alpha通道,然后创建阴影
BufferedImage shadowImage = shadowFilter.filter(alphaFilter.filter(localVacancy, null), null);
// 画上内阴影小图
vg.drawImage(shadowImage, null, null);
// 释放图像
vg.dispose();
// 画大图
// 创建画笔
Graphics2D g = artwork.createGraphics();
// 抗锯齿
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
// 画上图片
g.drawImage(thumbnail, null, null);
// 设置画图路径范围
g.setClip(path);
// 填充缺口透明度 颜色混合,不透明在上
g.setComposite(AlphaComposite.SrcAtop);
// 填充一层白色的透明蒙版,透明度越高,白色越深 alpha:0-255
g.setColor(color);
g.fill(path);
//描边
if (artworkBorderColor != null) {
g.setColor(artworkBorderColor);
g.setStroke(new BasicStroke(artworkBorderWidth));
g.draw(path);
}
// 画上内阴影小图
g.drawImage(shadowImage, null, null);
// 释放图像
g.dispose();
// 裁剪掉多余的透明背景
localVacancy = ImageUtils.getSubimage(localVacancy, (int) (x * wScale), 0, (int) Math.ceil(path.getBounds().getWidth()), h);
BufferedImage[] bufferedImages = new BufferedImage[2];
if (isFast) {
// 添加阴影
bufferedImages[0] = localVacancy;
bufferedImages[1] = artwork;
} else {
// 大图缩放
bufferedImages[0] = ImageUtils.convertImageToARGB(artwork.getScaledInstance(width, height, imageQuality));
// 缩放时,除以放大比例
bufferedImages[1] = ImageUtils.convertImageToARGB(localVacancy.getScaledInstance((int) (path.getBounds().getWidth() / wScale), height, imageQuality));
}
return bufferedImages;
}
/**
* 绘制拼图块的路径
*
* @param xScale x轴放大比例
* @param yScale y轴放大比例
* @return
*/
private GeneralPath paintBrick(double xScale, double yScale) {
double x = this.x * xScale;
double y = this.y * yScale;
// 直线移动的基础距离
double hMoveL = vwh / 3f * yScale;
double wMoveL = vwh / 3f * xScale;
GeneralPath path = new GeneralPath();
path.moveTo(x, y);
path.lineTo(x + wMoveL, y);
// 上面的圆弧正东方向0°,顺时针负数,逆时针正数
path.append(arc(x + wMoveL, y - hMoveL / 2, wMoveL, hMoveL, 180, -180), true);
path.lineTo(x + wMoveL * 3, y);
path.lineTo(x + wMoveL * 3, y + hMoveL);
// 右边的圆弧
path.append(arc(x + wMoveL * 2 + wMoveL / 2, y + hMoveL, wMoveL, hMoveL, 90, -180), true);
path.lineTo(x + wMoveL * 3, y + hMoveL * 3);
path.lineTo(x, y + hMoveL * 3);
path.lineTo(x, y + hMoveL * 2);
// 左边的内圆弧
path.append(arc(x - wMoveL / 2, y + hMoveL, wMoveL, hMoveL, -90, 180), true);
path.lineTo(x, y);
path.closePath();
return path;
}
/**
* 绘制圆形、圆弧或者是椭圆形
* 正东方向0°,顺时针负数,逆时针正数
*
* @param x 左上角的x坐标
* @param y 左上角的y坐标
* @param w 宽
* @param h 高
* @param start 开始的角度
* @param extent 结束的角度
* @return
*/
private Arc2D arc(double x, double y, double w, double h, double start, double extent) {
return new Arc2D.Double(x, y, w, h, start, extent, Arc2D.OPEN);
}
/**
* 透明背景
*
* @param bufferedImage
* @return
*/
private BufferedImage translucent(BufferedImage bufferedImage) {
Graphics2D g = bufferedImage.createGraphics();
bufferedImage = g.getDeviceConfiguration().createCompatibleImage(bufferedImage.getWidth(), bufferedImage.getHeight(), Transparency.TRANSLUCENT);
g.dispose();
return bufferedImage;
}
/**
* 随机数
*
* @param min
* @param max
* @return
*/
private static int random(int min, int max) {
return RANDOM.ints(min, max + 1).findFirst().getAsInt();
}
}