feat: Phase 3-5 管理后台前端+统计监控+部署配置
deploy-test / build-and-deploy-test (push) Successful in 2m41s

Phase 3: 管理后台前端 (Vue3+Vite+Element Plus)
- 登录页面/JWT认证/Pinia状态管理
- 首页大盘/站点管理/验证码日志/IP黑名单/套餐管理/系统设置
- 7个页面完整路由+API对接

Phase 4: 统计监控模块
- CaptchaLogAspect AOP自动记录
- RealtimeStatsService Redis实时计数
- HistoryStatsService 历史查询
- AnomalyDetectionService 异常检测

Phase 5: 部署和测试
- docker-compose.yml 5服务编排
- application-prod.yml 外部化配置
- CaptchaApiIntegrationTest 集成测试
- 前端 Dockerfile + nginx.conf
This commit is contained in:
abcv7
2026-08-26 13:44:08 +08:00
parent d1cd371061
commit 1164b51c64
67 changed files with 5510 additions and 829 deletions
@@ -0,0 +1,181 @@
import "./angle.scss"
import {Dom, CommonCaptcha, destroyEvent} from "../common/common.js"
/**
* 角度验证验证码 - 用户需要旋转图片到正确角度
*/
const TYPE = "ANGLE"
function getTemplate(styleConfig) {
return `
<div id="tianai-captcha" class="tianai-captcha-slider tianai-captcha-angle">
<div class="click-tip">
<span id="tianai-captcha-click-track-font" style="font-size: ${styleConfig.i18n?.angle_title_size || '14px'}">${styleConfig.i18n?.angle_title || '旋转图片到正确角度'}</span>
</div>
<div class="content">
<div class="rotate-container">
<div class="rotate-img" id="rotate-img">
<img id="tianai-captcha-rotate-bg-img" src="" alt/>
</div>
<div class="rotate-indicator" id="rotate-indicator">
<div class="indicator-line" id="indicator-line"></div>
</div>
</div>
<div class="slider-bar" id="tianai-captcha-slider-bar">
<div class="slider-bar-bg"></div>
<div class="slider-bar-track" id="tianai-captcha-slider-track"></div>
<div class="slider-bar-btn" id="tianai-captcha-slider-btn">
<div class="slider-btn-icon"></div>
</div>
</div>
<div class="tianai-captcha-tips" id="tianai-captcha-tips"></div>
</div>
</div>
`;
}
class Angle extends CommonCaptcha{
constructor(boxEl, styleConfig) {
super();
this.boxEl = boxEl;
this.styleConfig = styleConfig;
this.type = TYPE;
this.currentCaptchaData = {};
this.isDragging = false;
this.startX = 0;
this.currentAngle = 0;
}
init(captchaData, endCallback, loadSuccessCallback) {
// 重载样式
this.destroy();
this.boxEl.append(getTemplate(this.styleConfig));
this.el = this.boxEl.find("#tianai-captcha");
// 载入验证码
this.loadCaptchaForData(this, captchaData);
this.endCallback = endCallback;
if (loadSuccessCallback) {
// 加载成功
loadSuccessCallback(this);
}
return this;
}
bindDragEvents() {
const sliderBtn = document.getElementById('tianai-captcha-slider-btn');
const rotateImg = document.getElementById('rotate-img');
if (!sliderBtn || !rotateImg) return;
// 鼠标按下
sliderBtn.addEventListener('mousedown', (e) => {
this.isDragging = true;
this.startX = e.pageX;
this.currentCaptchaData.startTime = new Date();
e.preventDefault();
});
// 鼠标移动
document.addEventListener('mousemove', (e) => {
if (!this.isDragging) return;
const moveX = e.pageX - this.startX;
const sliderBar = document.getElementById('tianai-captcha-slider-bar');
const barWidth = sliderBar ? sliderBar.offsetWidth - 40 : 200;
const left = Math.max(0, Math.min(moveX, barWidth));
sliderBtn.style.left = left + 'px';
document.getElementById('tianai-captcha-slider-track').style.width = (left + 20) + 'px';
// 计算旋转角度
this.currentAngle = (left / barWidth) * 360;
rotateImg.style.transform = 'rotate(' + this.currentAngle + 'deg)';
// 更新指示器
this.updateIndicator();
// 记录轨迹
if (!this.currentCaptchaData.trackList) {
this.currentCaptchaData.trackList = [];
}
this.currentCaptchaData.trackList.push({
x: e.pageX,
y: e.pageY,
angle: this.currentAngle,
t: new Date().getTime() - this.currentCaptchaData.startTime.getTime()
});
});
// 鼠标松开
document.addEventListener('mouseup', () => {
if (!this.isDragging) return;
this.isDragging = false;
this.currentCaptchaData.stopTime = new Date();
this.currentCaptchaData.currentAngle = this.currentAngle;
this.endCallback(this.currentCaptchaData, this);
});
// 触摸事件
sliderBtn.addEventListener('touchstart', (e) => {
this.isDragging = true;
this.startX = e.touches[0].pageX;
this.currentCaptchaData.startTime = new Date();
e.preventDefault();
});
document.addEventListener('touchmove', (e) => {
if (!this.isDragging) return;
const moveX = e.touches[0].pageX - this.startX;
const sliderBar = document.getElementById('tianai-captcha-slider-bar');
const barWidth = sliderBar ? sliderBar.offsetWidth - 40 : 200;
const left = Math.max(0, Math.min(moveX, barWidth));
sliderBtn.style.left = left + 'px';
document.getElementById('tianai-captcha-slider-track').style.width = (left + 20) + 'px';
this.currentAngle = (left / barWidth) * 360;
rotateImg.style.transform = 'rotate(' + this.currentAngle + 'deg)';
this.updateIndicator();
});
document.addEventListener('touchend', () => {
if (!this.isDragging) return;
this.isDragging = false;
this.currentCaptchaData.stopTime = new Date();
this.currentCaptchaData.currentAngle = this.currentAngle;
this.endCallback(this.currentCaptchaData, this);
});
}
updateIndicator() {
const indicatorLine = document.getElementById('indicator-line');
if (indicatorLine) {
indicatorLine.style.transform = 'rotate(' + this.currentAngle + 'deg)';
}
}
destroy () {
const existsCaptchaEl = this.boxEl.children("#tianai-captcha");
if (existsCaptchaEl) {
existsCaptchaEl.remove();
}
destroyEvent();
}
loadCaptchaForData (that, data) {
const bgImg = that.el.find("#tianai-captcha-rotate-bg-img");
bgImg.on("load",() => {
that.currentCaptchaData = {
bgImageWidth: bgImg.width(),
bgImageHeight: bgImg.height(),
startTime: new Date(),
trackList: [],
targetAngle: data.data.targetAngle || 0,
currentAngle: 0
};
that.currentCaptchaData.currentCaptchaId = data.data.id;
// 绑定滑动事件
setTimeout(() => that.bindDragEvents(), 100);
})
bgImg.attr("src", data.data.backgroundImage);
}
}
export default Angle;
@@ -0,0 +1,107 @@
#tianai-captcha.tianai-captcha-angle {
box-sizing: border-box;
.click-tip {
position: relative;
height: 40px;
width: 100%;
display: flex;
align-items: center;
#tianai-captcha-click-track-font {
font-size: 14px;
color: #333;
}
}
.content {
.rotate-container {
position: relative;
width: 200px;
height: 200px;
margin: 0 auto;
border-radius: 50%;
overflow: hidden;
border: 3px solid #e8e8e8;
.rotate-img {
width: 100%;
height: 100%;
transition: transform 0.1s;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.rotate-indicator {
position: absolute;
top: 50%;
left: 50%;
width: 2px;
height: 50%;
background-color: transparent;
transform-origin: bottom center;
transform: translate(-50%, -100%);
.indicator-line {
width: 100%;
height: 100%;
background-color: #ff4444;
transform-origin: bottom center;
}
}
}
.slider-bar {
position: relative;
height: 40px;
margin-top: 3px;
.slider-bar-bg {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
border-radius: 4px;
background-color: #e8e8e8;
}
.slider-bar-track {
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-radius: 4px;
background-image: linear-gradient(90deg, #409eff, #66b1ff);
transition: width 0.1s;
}
.slider-bar-btn {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 40px;
border-radius: 4px;
background-image: linear-gradient(173deg, hsl(38.09deg 91% 57.89%) 0%, hsl(38.09deg 89.38% 71.74%) 100%);
cursor: move;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
.slider-btn-icon {
width: 0;
height: 0;
border-style: solid;
border-width: 6px 0 6px 8px;
border-color: transparent transparent transparent #fff;
}
}
}
}
}
+45 -4
View File
@@ -4,6 +4,14 @@ import Rotate from "./rotate/rotate";
import Concat from "./concat/concat";
import Disable from "./disable/disable";
import WordImageClick from "./word_image_click/word_image_click";
import IconClick from "./icon_click/icon_click";
import WordOrderClick from "./word_order_click/word_order_click";
import Scratch from "./scratch/scratch";
import Jigsaw from "./jigsaw/jigsaw";
import CurveSlider from "./curve_slider/curve_slider";
import Angle from "./angle/angle";
import CurveDraw from "./curve_draw/curve_draw";
import ProofOfWork from "./proof_of_work/proof_of_work";
import {CaptchaConfig, wrapConfig, wrapStyle} from "./config/config";
import {clearAllPreventDefault} from "./common/common";
const template =
@@ -11,13 +19,30 @@ const template =
<div id="tianai-captcha-parent">
<div id="tianai-captcha-bg-img"></div>
<div id="tianai-captcha-box">
<div id="tianai-captcha-loading" class="loading"></div>
<div id="tianai-captcha-loading" class="loading">
<div class="loading-spinner"></div>
<div class="loading-text">加载中...</div>
</div>
</div>
<!-- 底部 -->
<div class="slider-bottom">
<img class="logo" id="tianai-captcha-logo" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeCAMAAAAM7l6QAAAAMFBMVEVHcEz3tkX3tkX3tkX3tkX3tkX3tkX3tkX3tkX3tkX3tkX3tkX3tkX3tkX3tkX3tkVmTmjZAAAAD3RSTlMASbTm8wh12hOGoCNiyTV98jvOAAABB0lEQVR42nVT0aIFEQiMorD0/397Lc5a7J0n1UylgIniLRKyDcbBDudZH2DYCAabn3PmTrjeUX+7rJGWx0SqVpzReAfTtKU5fgVCNfxWjB69USUDGwoOiauHpZEpSr0tCx8ILb3Dm3WgBbAlifAJk6+Ww6wqEUmpmIorQVZ1JtqKnDMjkb7AgIpO/wMCaQbuBuEtsBUxhuD9daUaZnApiQB8NAKotMwirGGr6mbXpPnHLHDmy6oy3FgP+1j8IBdVklFc01xUJwv3NR0rIeXV5zpzdlruiijzNq/ufOeKWzZLP3160u5P8RjT1M+HHFtx+PwGyOZqT/D8ROOfjOInTLBIHjy/hvwHxkwPu5cCE1QAAAAASUVORK5CYII=" id="tianai-captcha-logo"></img>
<div class="close-btn" id="tianai-captcha-slider-close-btn"></div>
<div class="refresh-btn" id="tianai-captcha-slider-refresh-btn"></div>
<div class="bottom-left">
<img class="logo" id="tianai-captcha-logo" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 120 28'%3E%3Ctext x='0' y='22' font-family='Arial,sans-serif' font-size='18' font-weight='bold' fill='%23667eea'%3ETianAI%3C/text%3E%3C/svg%3E" id="tianai-captcha-logo">
</div>
<div class="bottom-right">
<div class="icon-btn refresh-btn" id="tianai-captcha-slider-refresh-btn" title="刷新">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<path d="M23 4v6h-6M1 20v-6h6"/>
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/>
</svg>
</div>
<div class="icon-btn close-btn" id="tianai-captcha-slider-close-btn" title="关闭">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</div>
</div>
</div>
</div>
`;
@@ -33,6 +58,22 @@ function createCaptchaByType(type, tac) {
return new Concat(box, styleConfig);
case "WORD_IMAGE_CLICK":
return new WordImageClick(box, styleConfig);
case "ICON_CLICK":
return new IconClick(box, styleConfig);
case "WORD_ORDER_CLICK":
return new WordOrderClick(box, styleConfig);
case "SCRATCH":
return new Scratch(box, styleConfig);
case "JIGSAW":
return new Jigsaw(box, styleConfig);
case "CURVE_SLIDER":
return new CurveSlider(box, styleConfig);
case "ANGLE":
return new Angle(box, styleConfig);
case "CURVE_DRAW":
return new CurveDraw(box, styleConfig);
case "PROOF_OF_WORK":
return new ProofOfWork(box, styleConfig);
case "DISABLED":
return new Disable(box, styleConfig);
default:
+85 -21
View File
@@ -1,39 +1,58 @@
#tianai-captcha-parent {
box-shadow: 0 0 11px 0 #999999;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
width: 318px;
height: 318px;
overflow: hidden;
position: relative;
z-index: 997;
box-sizing: border-box;
border-radius: 5px;
border-radius: 8px;
padding: 8px;
#tianai-captcha-box {
height: 260px;
width: 100%;
position: relative;
overflow: hidden;
border-radius: 6px;
.loading {
width: 120px;
height: 20px;
-webkit-mask: linear-gradient(90deg, #000 70%, #0000 0) 0/20%;
background: linear-gradient(#f7b645 0 0) 0 / 0% no-repeat #dddddd6b;
animation: cartoon 1s infinite steps(6);
margin: 120px auto;
@keyframes cartoon {
100% {
background-size: 120%;
}
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: #fafafa;
.loading-spinner {
width: 36px;
height: 36px;
border: 3px solid #f0f0f0;
border-top-color: #f7b645;
border-radius: 50%;
animation: tianai-spin 0.8s linear infinite;
}
.loading-text {
margin-top: 12px;
font-size: 12px;
color: #999;
}
}
@keyframes tianai-spin {
to { transform: rotate(360deg); }
}
#tianai-captcha {
transform-style: preserve-3d;
will-change: transform;
transition-duration: .45s;
//transition-timing-function: cubic-bezier(0.36, 0.3, 0.42, 1.5);
transition-duration: 0.45s;
transform: translateX(-300px);
}
}
#tianai-captcha-bg-img {
background-color: #fff;
background-position: top;
@@ -44,11 +63,54 @@
top: 0;
left: 0;
position: absolute;
border-radius: 6px;
//background-image: url("");
border-radius: 8px;
}
.slider-bottom {
.slider-bottom {
display: flex;
justify-content: space-between;
align-items: center;
height: 26px;
width: 100%;
padding: 0 2px;
.bottom-left {
display: flex;
align-items: center;
.logo {
height: 22px;
}
}
.bottom-right {
display: flex;
align-items: center;
gap: 4px;
.icon-btn {
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
cursor: pointer;
color: #999;
transition: all 0.2s;
&:hover {
background: #f0f0f0;
color: #666;
}
svg {
width: 14px;
height: 14px;
}
}
}
.close-btn {
width: 20px;
height: 20px;
@@ -59,6 +121,7 @@
margin-right: 2px;
cursor: pointer;
}
.refresh-btn {
width: 20px;
height: 20px;
@@ -69,13 +132,16 @@
margin-right: 10px;
cursor: pointer;
}
.logo {
height: 30px;
float: left;
}
height: 19px;
width: 100%;
}
.slider-move-shadow {
animation: myanimation 2s infinite;
height: 100%;
@@ -88,20 +154,18 @@
box-shadow: 1px 1px 1px #fff;
border-radius: 50%;
}
#tianai-captcha-slider-move-track-mask {
border-width: 1px;
border-style: solid;
border-color: #00f4ab;
width: 0;
height: 32px;
background-color: #a9ffe5;
opacity: .5;
opacity: 0.5;
position: absolute;
top: -1px;
left: -1px;
border-radius: 5px;
}
}
@@ -0,0 +1,195 @@
import "./curve_draw.scss"
import {Dom, CommonCaptcha, destroyEvent} from "../common/common.js"
/**
* 曲线绘制验证码 - 用户需要绘制指定曲线
*/
const TYPE = "CURVE_DRAW"
function getTemplate(styleConfig) {
return `
<div id="tianai-captcha" class="tianai-captcha-slider tianai-captcha-curve-draw">
<div class="click-tip">
<span id="tianai-captcha-click-track-font" style="font-size: ${styleConfig.i18n?.curve_draw_title_size || '14px'}">${styleConfig.i18n?.curve_draw_title || '请沿虚线绘制曲线'}</span>
</div>
<div class="content">
<div class="draw-container" id="draw-container">
<canvas id="tianai-captcha-draw-canvas" class="draw-canvas"></canvas>
</div>
<div class="draw-actions">
<button class="draw-btn reset-btn" id="draw-reset-btn">重绘</button>
<button class="draw-btn confirm-btn" id="draw-confirm-btn">确定</button>
</div>
<div class="tianai-captcha-tips" id="tianai-captcha-tips"></div>
</div>
</div>
`;
}
class CurveDraw extends CommonCaptcha{
constructor(boxEl, styleConfig) {
super();
this.boxEl = boxEl;
this.styleConfig = styleConfig;
this.type = TYPE;
this.currentCaptchaData = {};
this.isDrawing = false;
this.drawPoints = [];
}
init(captchaData, endCallback, loadSuccessCallback) {
// 重载样式
this.destroy();
this.boxEl.append(getTemplate(this.styleConfig));
this.el = this.boxEl.find("#tianai-captcha");
// 载入验证码
this.loadCaptchaForData(this, captchaData);
this.endCallback = endCallback;
if (loadSuccessCallback) {
// 加载成功
loadSuccessCallback(this);
}
return this;
}
initCanvas() {
const canvas = document.getElementById('tianai-captcha-draw-canvas');
const container = document.getElementById('draw-container');
if (!canvas || !container) return;
const ctx = canvas.getContext('2d');
const rect = container.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
// 绘制提示曲线(虚线)
this.drawGuideCurve(ctx, canvas.width, canvas.height);
// 绑定绘制事件
this.bindDrawEvents(canvas, ctx);
}
drawGuideCurve(ctx, width, height) {
const curveData = this.currentCaptchaData.curveData;
if (!curveData || curveData.length < 2) return;
ctx.beginPath();
ctx.strokeStyle = 'rgba(64, 158, 255, 0.5)';
ctx.lineWidth = 2;
ctx.setLineDash([5, 5]);
ctx.moveTo(curveData[0].x * width, curveData[0].y * height);
for (let i = 1; i < curveData.length; i++) {
ctx.lineTo(curveData[i].x * width, curveData[i].y * height);
}
ctx.stroke();
}
bindDrawEvents(canvas, ctx) {
const draw = (e) => {
if (!this.isDrawing) return;
const rect = canvas.getBoundingClientRect();
let x, y;
if (e.touches) {
x = e.touches[0].clientX - rect.left;
y = e.touches[0].clientY - rect.top;
} else {
x = e.offsetX;
y = e.offsetY;
}
this.drawPoints.push({x, y});
ctx.globalCompositeOperation = 'source-over';
ctx.strokeStyle = '#409eff';
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
if (this.drawPoints.length > 1) {
const prev = this.drawPoints[this.drawPoints.length - 2];
ctx.beginPath();
ctx.moveTo(prev.x, prev.y);
ctx.lineTo(x, y);
ctx.stroke();
}
};
// 鼠标事件
canvas.addEventListener('mousedown', (e) => {
this.isDrawing = true;
this.currentCaptchaData.startTime = new Date();
this.drawPoints = [];
ctx.clearRect(0, 0, canvas.width, canvas.height);
this.drawGuideCurve(ctx, canvas.width, canvas.height);
draw(e);
});
canvas.addEventListener('mousemove', draw);
canvas.addEventListener('mouseup', () => {
this.isDrawing = false;
});
canvas.addEventListener('mouseleave', () => {
this.isDrawing = false;
});
// 触摸事件
canvas.addEventListener('touchstart', (e) => {
this.isDrawing = true;
this.currentCaptchaData.startTime = new Date();
this.drawPoints = [];
ctx.clearRect(0, 0, canvas.width, canvas.height);
this.drawGuideCurve(ctx, canvas.width, canvas.height);
e.preventDefault();
draw(e);
});
canvas.addEventListener('touchmove', (e) => {
e.preventDefault();
draw(e);
});
canvas.addEventListener('touchend', () => {
this.isDrawing = false;
});
// 重绘按钮
const resetBtn = document.getElementById('draw-reset-btn');
if (resetBtn) {
resetBtn.addEventListener('click', () => {
this.drawPoints = [];
ctx.clearRect(0, 0, canvas.width, canvas.height);
this.drawGuideCurve(ctx, canvas.width, canvas.height);
});
}
// 确定按钮
const confirmBtn = document.getElementById('draw-confirm-btn');
if (confirmBtn) {
confirmBtn.addEventListener('click', () => {
if (this.drawPoints.length > 0) {
this.currentCaptchaData.stopTime = new Date();
this.currentCaptchaData.drawPoints = this.drawPoints;
this.endCallback(this.currentCaptchaData, this);
}
});
}
}
destroy () {
const existsCaptchaEl = this.boxEl.children("#tianai-captcha");
if (existsCaptchaEl) {
existsCaptchaEl.remove();
}
destroyEvent();
}
loadCaptchaForData (that, data) {
that.currentCaptchaData = {
startTime: new Date(),
drawPoints: [],
curveData: data.data.curveData || []
};
that.currentCaptchaData.currentCaptchaId = data.data.id;
// 初始化Canvas
setTimeout(() => that.initCanvas(), 100);
}
}
export default CurveDraw;
@@ -0,0 +1,64 @@
#tianai-captcha.tianai-captcha-curve-draw {
box-sizing: border-box;
.click-tip {
position: relative;
height: 40px;
width: 100%;
display: flex;
align-items: center;
#tianai-captcha-click-track-font {
font-size: 14px;
color: #333;
}
}
.content {
.draw-container {
width: 100%;
height: 180px;
position: relative;
overflow: hidden;
border-radius: 4px;
border: 1px solid #e8e8e8;
.draw-canvas {
width: 100%;
height: 100%;
cursor: crosshair;
touch-action: none;
}
}
.draw-actions {
display: flex;
gap: 10px;
margin-top: 8px;
.draw-btn {
flex: 1;
height: 32px;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: opacity 0.2s;
&:hover {
opacity: 0.9;
}
&.reset-btn {
background-color: #e8e8e8;
color: #666;
}
&.confirm-btn {
background-image: linear-gradient(173deg, hsl(38.09deg 91% 57.89%) 0%, hsl(38.09deg 89.38% 71.74%) 100%);
color: #fff;
}
}
}
}
}
@@ -0,0 +1,205 @@
import "./curve_slider.scss"
import {Dom, CommonCaptcha, destroyEvent} from "../common/common.js"
/**
* 曲线滑块验证码 - 用户需要沿曲线轨迹滑动
*/
const TYPE = "CURVE_SLIDER"
function getTemplate(styleConfig) {
return `
<div id="tianai-captcha" class="tianai-captcha-slider tianai-captcha-curve-slider">
<div class="click-tip">
<span id="tianai-captcha-click-track-font" style="font-size: ${styleConfig.i18n?.curve_slider_title_size || '14px'}">${styleConfig.i18n?.curve_slider_title || '沿曲线轨迹滑动到终点'}</span>
</div>
<div class="content">
<div class="bg-img-div">
<img id="tianai-captcha-slider-bg-img" src="" alt/>
<canvas id="tianai-captcha-curve-canvas" class="curve-canvas"></canvas>
<div class="curve-point" id="curve-point"></div>
<div class="curve-end-point" id="curve-end-point"></div>
</div>
<div class="slider-bar" id="tianai-captcha-slider-bar">
<div class="slider-bar-bg"></div>
<div class="slider-bar-track" id="tianai-captcha-slider-track"></div>
<div class="slider-bar-btn" id="tianai-captcha-slider-btn">
<div class="slider-btn-icon"></div>
</div>
</div>
<div class="tianai-captcha-tips" id="tianai-captcha-tips"></div>
</div>
</div>
`;
}
class CurveSlider extends CommonCaptcha{
constructor(boxEl, styleConfig) {
super();
this.boxEl = boxEl;
this.styleConfig = styleConfig;
this.type = TYPE;
this.currentCaptchaData = {};
this.isDragging = false;
this.startX = 0;
}
init(captchaData, endCallback, loadSuccessCallback) {
// 重载样式
this.destroy();
this.boxEl.append(getTemplate(this.styleConfig));
this.el = this.boxEl.find("#tianai-captcha");
// 载入验证码
this.loadCaptchaForData(this, captchaData);
this.endCallback = endCallback;
if (loadSuccessCallback) {
// 加载成功
loadSuccessCallback(this);
}
return this;
}
initCanvas() {
const canvas = document.getElementById('tianai-captcha-curve-canvas');
const bgImg = document.getElementById('tianai-captcha-slider-bg-img');
if (!canvas || !bgImg) return;
const ctx = canvas.getContext('2d');
canvas.width = bgImg.width;
canvas.height = bgImg.height;
// 绘制曲线
this.drawCurve(ctx, canvas.width, canvas.height);
// 绑定滑动事件
this.bindDragEvents(canvas);
}
drawCurve(ctx, width, height) {
const curveData = this.currentCaptchaData.curveData;
if (!curveData || curveData.length < 2) return;
ctx.beginPath();
ctx.strokeStyle = 'rgba(255, 165, 0, 0.8)';
ctx.lineWidth = 3;
ctx.setLineDash([5, 5]);
ctx.moveTo(curveData[0].x, curveData[0].y);
for (let i = 1; i < curveData.length; i++) {
ctx.lineTo(curveData[i].x, curveData[i].y);
}
ctx.stroke();
// 绘制起点
ctx.beginPath();
ctx.fillStyle = '#409eff';
ctx.arc(curveData[0].x, curveData[0].y, 8, 0, Math.PI * 2);
ctx.fill();
// 绘制终点
const endPoint = curveData[curveData.length - 1];
ctx.beginPath();
ctx.fillStyle = '#ff4444';
ctx.arc(endPoint.x, endPoint.y, 8, 0, Math.PI * 2);
ctx.fill();
}
bindDragEvents(canvas) {
const point = document.getElementById('curve-point');
const sliderBtn = document.getElementById('tianai-captcha-slider-btn');
if (!point || !sliderBtn) return;
// 鼠标按下
sliderBtn.addEventListener('mousedown', (e) => {
this.isDragging = true;
this.startX = e.pageX;
this.currentCaptchaData.startTime = new Date();
e.preventDefault();
});
// 鼠标移动
document.addEventListener('mousemove', (e) => {
if (!this.isDragging) return;
const moveX = e.pageX - this.startX;
const sliderBar = document.getElementById('tianai-captcha-slider-bar');
const barWidth = sliderBar ? sliderBar.offsetWidth - 40 : 200;
const left = Math.max(0, Math.min(moveX, barWidth));
sliderBtn.style.left = left + 'px';
document.getElementById('tianai-captcha-slider-track').style.width = (left + 20) + 'px';
// 记录轨迹
if (!this.currentCaptchaData.trackList) {
this.currentCaptchaData.trackList = [];
}
this.currentCaptchaData.trackList.push({
x: e.pageX,
y: e.pageY,
t: new Date().getTime() - this.currentCaptchaData.startTime.getTime()
});
});
// 鼠标松开
document.addEventListener('mouseup', () => {
if (!this.isDragging) return;
this.isDragging = false;
this.currentCaptchaData.stopTime = new Date();
const sliderBtn = document.getElementById('tianai-captcha-slider-btn');
this.currentCaptchaData.left = sliderBtn ? sliderBtn.offsetLeft : 0;
this.endCallback(this.currentCaptchaData, this);
});
// 触摸事件
sliderBtn.addEventListener('touchstart', (e) => {
this.isDragging = true;
this.startX = e.touches[0].pageX;
this.currentCaptchaData.startTime = new Date();
e.preventDefault();
});
document.addEventListener('touchmove', (e) => {
if (!this.isDragging) return;
const moveX = e.touches[0].pageX - this.startX;
const sliderBar = document.getElementById('tianai-captcha-slider-bar');
const barWidth = sliderBar ? sliderBar.offsetWidth - 40 : 200;
const left = Math.max(0, Math.min(moveX, barWidth));
sliderBtn.style.left = left + 'px';
document.getElementById('tianai-captcha-slider-track').style.width = (left + 20) + 'px';
});
document.addEventListener('touchend', () => {
if (!this.isDragging) return;
this.isDragging = false;
this.currentCaptchaData.stopTime = new Date();
const sliderBtn = document.getElementById('tianai-captcha-slider-btn');
this.currentCaptchaData.left = sliderBtn ? sliderBtn.offsetLeft : 0;
this.endCallback(this.currentCaptchaData, this);
});
}
destroy () {
const existsCaptchaEl = this.boxEl.children("#tianai-captcha");
if (existsCaptchaEl) {
existsCaptchaEl.remove();
}
destroyEvent();
}
loadCaptchaForData (that, data) {
const bgImg = that.el.find("#tianai-captcha-slider-bg-img");
bgImg.on("load",() => {
that.currentCaptchaData = {
bgImageWidth: bgImg.width(),
bgImageHeight: bgImg.height(),
startTime: new Date(),
trackList: [],
curveData: data.data.curveData || []
};
that.currentCaptchaData.currentCaptchaId = data.data.id;
// 初始化Canvas
setTimeout(() => that.initCanvas(), 100);
})
bgImg.attr("src", data.data.backgroundImage);
}
}
export default CurveSlider;
@@ -0,0 +1,114 @@
#tianai-captcha.tianai-captcha-curve-slider {
box-sizing: border-box;
.click-tip {
position: relative;
height: 40px;
width: 100%;
display: flex;
align-items: center;
#tianai-captcha-click-track-font {
font-size: 14px;
color: #333;
}
}
.content {
.bg-img-div {
position: relative;
height: 180px;
overflow: hidden;
border-radius: 4px;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.curve-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.curve-point {
position: absolute;
width: 16px;
height: 16px;
border-radius: 50%;
background-color: #409eff;
border: 2px solid #fff;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
transform: translate(-50%, -50%);
display: none;
}
.curve-end-point {
position: absolute;
width: 16px;
height: 16px;
border-radius: 50%;
background-color: #ff4444;
border: 2px solid #fff;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
transform: translate(-50%, -50%);
display: none;
}
}
.slider-bar {
position: relative;
height: 40px;
margin-top: 3px;
.slider-bar-bg {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
border-radius: 4px;
background-color: #e8e8e8;
}
.slider-bar-track {
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-radius: 4px;
background-image: linear-gradient(90deg, #409eff, #66b1ff);
transition: width 0.1s;
}
.slider-bar-btn {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 40px;
border-radius: 4px;
background-image: linear-gradient(173deg, hsl(38.09deg 91% 57.89%) 0%, hsl(38.09deg 89.38% 71.74%) 100%);
cursor: move;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
transition: transform 0.1s;
.slider-btn-icon {
width: 0;
height: 0;
border-style: solid;
border-width: 6px 0 6px 8px;
border-color: transparent transparent transparent #fff;
}
}
}
}
}
@@ -0,0 +1,104 @@
import "./icon_click.scss"
import {Dom, CommonCaptcha, move, initConfig, destroyEvent} from "../common/common.js"
/**
* 图标点选验证码
*/
const TYPE = "ICON_CLICK"
function getTemplate(styleConfig) {
return `
<div id="tianai-captcha" class="tianai-captcha-slider tianai-captcha-icon-click">
<div class="click-tip">
<span id="tianai-captcha-click-track-font" style="font-size: ${styleConfig.i18n?.icon_click_title_size || '14px'}">${styleConfig.i18n?.icon_click_title || '请依次点击图中的图标'}</span>
<img src="" id="tianai-captcha-tip-img" class="tip-img">
</div>
<div class="content">
<div class="bg-img-div">
<img id="tianai-captcha-slider-bg-img" src="" alt/>
<canvas id="tianai-captcha-slider-bg-canvas"></canvas>
<div id="bg-img-click-mask"></div>
</div>
<div class="tianai-captcha-tips" id="tianai-captcha-tips"></div>
</div>
<div class="click-confirm-btn">确定</div>
</div>
`;
}
class IconClick extends CommonCaptcha{
constructor(boxEl, styleConfig) {
super();
this.boxEl = boxEl;
this.styleConfig = styleConfig;
this.type = TYPE;
this.currentCaptchaData = {}
}
init(captchaData, endCallback, loadSuccessCallback) {
// 重载样式
this.destroy();
this.boxEl.append(getTemplate(this.styleConfig));
this.el = this.boxEl.find("#tianai-captcha");
// 载入验证码
this.loadCaptchaForData(this, captchaData);
this.endCallback = endCallback;
const moveFun = move.bind(null, this);
// 绑定事件
this.el.find("#bg-img-click-mask").click((event) => {
if(event.target.className === "click-span") {
return;
}
this.currentCaptchaData.clickCount++;
const trackList = this.currentCaptchaData.trackList;
if (this.currentCaptchaData.clickCount === 1) {
this.currentCaptchaData.startTime = new Date();
// move 轨迹
window.addEventListener("mousemove", moveFun);
this.currentCaptchaData.startX = event.offsetX;
this.currentCaptchaData.startY = event.offsetY;
}
const startTime = this.currentCaptchaData.startTime;
trackList.push({
x: Math.round(event.offsetX),
y: Math.round(event.offsetY),
type: "click",
t: (new Date().getTime() - startTime.getTime())
});
const left = event.offsetX - 12;
const top = event.offsetY - 12;
this.el.find("#bg-img-click-mask").append("<span class='click-span' style='left:" + left + "px;top: " + top + "px'>" + this.currentCaptchaData.clickCount + "</span>")
});
this.el.find(".click-confirm-btn").click(() => {
if (this.currentCaptchaData.clickCount > 0) {
// 校验
this.currentCaptchaData.stopTime = new Date();
window.removeEventListener("mousemove", moveFun);
this.endCallback(this.currentCaptchaData,this);
}
});
if (loadSuccessCallback) {
// 加载成功
loadSuccessCallback(this);
}
return this;
}
destroy () {
const existsCaptchaEl = this.boxEl.children("#tianai-captcha");
if (existsCaptchaEl) {
existsCaptchaEl.remove();
}
destroyEvent();
}
loadCaptchaForData (that, data) {
const bgImg = that.el.find("#tianai-captcha-slider-bg-img");
const tipImg = that.el.find("#tianai-captcha-tip-img");
bgImg.on("load",() => {
that.currentCaptchaData = initConfig(bgImg.width(), bgImg.height(), tipImg.width(), tipImg.height());
that.currentCaptchaData.currentCaptchaId = data.data.id;
})
bgImg.attr("src", data.data.backgroundImage);
tipImg.attr("src", data.data.templateImage);
}
}
export default IconClick;
@@ -0,0 +1,75 @@
#tianai-captcha.tianai-captcha-icon-click {
box-sizing: border-box;
.click-tip {
position: relative;
height: 40px;
width: 100%;
.tip-img {
height: 35px;
position: absolute;
right: 15px;
}
#tianai-captcha-click-track-font {
font-size: 14px;
display: inline-block;
height: 40px;
line-height: 40px;
position: absolute;
color: #333;
}
}
.slider-bottom {
position: relative;
top: 6px;
}
.content {
#bg-img-click-mask {
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
cursor: pointer;
.click-span {
position: absolute;
left: 0;
top: 0;
border-radius: 50%;
background-color: #409eff;
width: 24px;
height: 24px;
text-align: center;
line-height: 24px;
color: #fff;
border: 2px solid #fff;
box-sizing: content-box;
font-size: 12px;
font-weight: bold;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
}
}
.click-confirm-btn {
width: 100%;
height: 35px;
border-radius: 4px;
background-image: linear-gradient(173deg, hsl(38.09deg 91% 57.89%) 0%, hsl(38.09deg 89.38% 71.74%) 100%);
font-size: 15px;
text-align: center;
box-sizing: border-box;
line-height: 35px;
color: #fff;
margin-top: 3px;
cursor: pointer;
transition: opacity 0.2s;
&:hover {
opacity: 0.9;
}
}
}
@@ -0,0 +1,111 @@
import "./jigsaw.scss"
import {Dom, CommonCaptcha, move, initConfig, destroyEvent} from "../common/common.js"
/**
* 乱序拼图验证码 - 用户需要拖动拼图块到正确位置
*/
const TYPE = "JIGSAW"
function getTemplate(styleConfig) {
return `
<div id="tianai-captcha" class="tianai-captcha-slider tianai-captcha-jigsaw">
<div class="click-tip">
<span id="tianai-captcha-click-track-font" style="font-size: ${styleConfig.i18n?.jigsaw_title_size || '14px'}">${styleConfig.i18n?.jigsaw_title || '拖动拼图块到正确位置'}</span>
</div>
<div class="content">
<div class="bg-img-div">
<img id="tianai-captcha-slider-bg-img" src="" alt/>
<div class="jigsaw-block" id="jigsaw-block"></div>
</div>
<div class="slider-bar" id="tianai-captcha-slider-bar">
<div class="slider-bar-bg"></div>
<div class="slider-bar-track" id="tianai-captcha-slider-track">
</div>
<div class="slider-bar-btn" id="tianai-captcha-slider-btn">
<div class="slider-btn-icon">
<div class="click-mask-btn" id="tianai-captcha-click-mask-btn"></div>
</div>
</div>
</div>
<div class="tianai-captcha-tips" id="tianai-captcha-tips"></div>
</div>
</div>
`;
}
class Jigsaw extends CommonCaptcha{
constructor(boxEl, styleConfig) {
super();
this.boxEl = boxEl;
this.styleConfig = styleConfig;
this.type = TYPE;
this.currentCaptchaData = {};
}
init(captchaData, endCallback, loadSuccessCallback) {
// 重载样式
this.destroy();
this.boxEl.append(getTemplate(this.styleConfig));
this.el = this.boxEl.find("#tianai-captcha");
// 载入验证码
this.loadCaptchaForData(this, captchaData);
this.endCallback = endCallback;
const moveFun = move.bind(null, this);
// 绑定事件
this.el.find("#tianai-captcha-click-mask-btn").mousedown((event) => {
this.currentCaptchaData.moveFlag = true;
this.currentCaptchaData.startTime = new Date();
// move 轨迹
window.addEventListener("mousemove", moveFun);
this.currentCaptchaData.startX = event.pageX;
this.currentCaptchaData.startY = event.pageY;
this.el.find("#tianai-captcha-click-mask-btn").css("transition", "none");
});
window.addEventListener("mouseup", (event) => {
if (!this.currentCaptchaData.moveFlag) {
return;
}
this.currentCaptchaData.moveFlag = false;
this.currentCaptchaData.stopTime = new Date();
window.removeEventListener("mousemove", moveFun);
const left = this.el.find("#tianai-captcha-slider-btn").css("left") ? this.el.find("#tianai-captcha-slider-btn").css("left").split("px")[0] : 0;
this.currentCaptchaData.left = left;
this.endCallback(this.currentCaptchaData,this);
});
if (loadSuccessCallback) {
// 加载成功
loadSuccessCallback(this);
}
return this;
}
destroy () {
const existsCaptchaEl = this.boxEl.children("#tianai-captcha");
if (existsCaptchaEl) {
existsCaptchaEl.remove();
}
destroyEvent();
}
loadCaptchaForData (that, data) {
const bgImg = that.el.find("#tianai-captcha-slider-bg-img");
bgImg.on("load",() => {
that.currentCaptchaData = initConfig(bgImg.width(), bgImg.height(), data.data.blockSize, data.data.blockSize);
that.currentCaptchaData.currentCaptchaId = data.data.id;
// 设置拼图块位置
that.el.find("#jigsaw-block").css({
"width": data.data.blockSize + "px",
"height": data.data.blockSize + "px",
"left": data.data.blockPositionX + "px",
"top": data.data.blockPositionY + "px",
"background-image": "url(" + data.data.blockImage + ")",
"background-size": bgImg.width() + "px " + bgImg.height() + "px",
"background-position": "-" + data.data.blockPositionX + "px -" + data.data.blockPositionY + "px"
});
that.el.find("#tianai-captcha-slider-track").css("height", that.el.find("#tianai-captcha-slider-bar").outerHeight() + "px");
})
bgImg.attr("src", data.data.backgroundImage);
}
}
export default Jigsaw;
@@ -0,0 +1,114 @@
#tianai-captcha.tianai-captcha-jigsaw {
box-sizing: border-box;
.click-tip {
position: relative;
height: 40px;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
#tianai-captcha-click-track-font {
font-size: 14px;
color: #333;
}
}
.content {
position: relative;
.bg-img-div {
position: relative;
height: 180px;
overflow: hidden;
border-radius: 4px;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.jigsaw-block {
position: absolute;
cursor: move;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
border-radius: 4px;
transition: box-shadow 0.2s;
&:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
}
}
}
.slider-bar {
position: relative;
height: 40px;
margin-top: 3px;
.slider-bar-bg {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
border-radius: 4px;
background-color: #e8e8e8;
}
.slider-bar-track {
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-radius: 4px;
background-image: linear-gradient(90deg, #409eff, #66b1ff);
transition: width 0.1s;
}
.slider-bar-btn {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 40px;
border-radius: 4px;
background-image: linear-gradient(173deg, hsl(38.09deg 91% 57.89%) 0%, hsl(38.09deg 89.38% 71.74%) 100%);
cursor: move;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
transition: transform 0.1s;
.slider-btn-icon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
&::after {
content: '';
display: block;
width: 0;
height: 0;
border-style: solid;
border-width: 6px 0 6px 8px;
border-color: transparent transparent transparent #fff;
}
}
.click-mask-btn {
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
}
}
}
}
}
@@ -0,0 +1,196 @@
import "./proof_of_work.scss"
import {Dom, CommonCaptcha, destroyEvent} from "../common/common.js"
/**
* 工作量证明验证码 - 用户需要完成计算工作
*/
const TYPE = "PROOF_OF_WORK"
function getTemplate(styleConfig) {
return `
<div id="tianai-captcha" class="tianai-captcha-slider tianai-captcha-proof-of-work">
<div class="click-tip">
<span id="tianai-captcha-click-track-font" style="font-size: ${styleConfig.i18n?.proof_of_work_title_size || '14px'}">${styleConfig.i18n?.proof_of_work_title || '请完成工作量证明'}</span>
</div>
<div class="content">
<div class="pow-container">
<div class="pow-info">
<div class="pow-challenge" id="pow-challenge">
<span class="label">挑战:</span>
<span class="value" id="pow-challenge-value">-</span>
</div>
<div class="pow-difficulty" id="pow-difficulty">
<span class="label">难度:</span>
<span class="value" id="pow-difficulty-value">-</span>
</div>
</div>
<div class="pow-progress">
<div class="progress-bar" id="pow-progress-bar"></div>
<span class="progress-text" id="pow-progress-text">0%</span>
</div>
<div class="pow-status" id="pow-status">等待开始...</div>
</div>
<div class="pow-actions">
<button class="pow-btn start-btn" id="pow-start-btn">开始计算</button>
</div>
<div class="tianai-captcha-tips" id="tianai-captcha-tips"></div>
</div>
</div>
`;
}
class ProofOfWork extends CommonCaptcha{
constructor(boxEl, styleConfig) {
super();
this.boxEl = boxEl;
this.styleConfig = styleConfig;
this.type = TYPE;
this.currentCaptchaData = {};
this.isComputing = false;
this.worker = null;
}
init(captchaData, endCallback, loadSuccessCallback) {
// 重载样式
this.destroy();
this.boxEl.append(getTemplate(this.styleConfig));
this.el = this.boxEl.find("#tianai-captcha");
// 载入验证码
this.loadCaptchaForData(this, captchaData);
this.endCallback = endCallback;
if (loadSuccessCallback) {
// 加载成功
loadSuccessCallback(this);
}
return this;
}
bindEvents() {
const startBtn = document.getElementById('pow-start-btn');
if (!startBtn) return;
startBtn.addEventListener('click', () => {
if (this.isComputing) return;
this.startComputation();
});
}
startComputation() {
this.isComputing = true;
this.currentCaptchaData.startTime = new Date();
const startBtn = document.getElementById('pow-start-btn');
if (startBtn) {
startBtn.disabled = true;
startBtn.textContent = '计算中...';
}
// 更新状态
this.updateStatus('正在计算工作量证明...');
// 模拟计算过程(实际应用中应使用Web Worker)
this.simulateComputation();
}
simulateComputation() {
const challenge = this.currentCaptchaData.challenge || 'demo';
const difficulty = this.currentCaptchaData.difficulty || 4;
let nonce = 0;
const target = '0'.repeat(difficulty);
const compute = () => {
if (!this.isComputing) return;
// 简单的SHA-256模拟(实际应用中应使用真实的SHA-256)
const hash = this.simpleHash(challenge + nonce);
// 更新进度
const progress = Math.min(99, (nonce / 10000) * 100);
this.updateProgress(progress);
if (hash.startsWith(target)) {
// 找到解决方案
this.currentCaptchaData.nonce = nonce;
this.currentCaptchaData.hash = hash;
this.currentCaptchaData.stopTime = new Date();
this.updateProgress(100);
this.updateStatus('计算完成!');
this.endCallback(this.currentCaptchaData, this);
} else {
nonce++;
if (nonce % 100 === 0) {
// 每100次更新一次UI
setTimeout(compute, 0);
} else {
compute();
}
}
};
compute();
}
simpleHash(str) {
// 简单的哈希函数(仅用于演示)
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
// 转换为16进制字符串
return Math.abs(hash).toString(16).padStart(8, '0');
}
updateProgress(percent) {
const progressBar = document.getElementById('pow-progress-bar');
const progressText = document.getElementById('pow-progress-text');
if (progressBar) {
progressBar.style.width = percent + '%';
}
if (progressText) {
progressText.textContent = Math.round(percent) + '%';
}
}
updateStatus(text) {
const statusEl = document.getElementById('pow-status');
if (statusEl) {
statusEl.textContent = text;
}
}
destroy () {
this.isComputing = false;
const existsCaptchaEl = this.boxEl.children("#tianai-captcha");
if (existsCaptchaEl) {
existsCaptchaEl.remove();
}
destroyEvent();
}
loadCaptchaForData (that, data) {
that.currentCaptchaData = {
startTime: new Date(),
challenge: data.data.challenge || 'demo_challenge',
difficulty: data.data.difficulty || 4,
nonce: 0,
hash: ''
};
that.currentCaptchaData.currentCaptchaId = data.data.id;
// 更新UI
const challengeValue = document.getElementById('pow-challenge-value');
const difficultyValue = document.getElementById('pow-difficulty-value');
if (challengeValue) {
challengeValue.textContent = that.currentCaptchaData.challenge.substring(0, 20) + '...';
}
if (difficultyValue) {
difficultyValue.textContent = that.currentCaptchaData.difficulty;
}
// 绑定事件
setTimeout(() => that.bindEvents(), 100);
}
}
export default ProofOfWork;
@@ -0,0 +1,104 @@
#tianai-captcha.tianai-captcha-proof-of-work {
box-sizing: border-box;
.click-tip {
position: relative;
height: 40px;
width: 100%;
display: flex;
align-items: center;
#tianai-captcha-click-track-font {
font-size: 14px;
color: #333;
}
}
.content {
.pow-container {
background-color: #f5f5f5;
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
.pow-info {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
.pow-challenge,
.pow-difficulty {
font-size: 12px;
color: #666;
.label {
margin-right: 4px;
}
.value {
color: #333;
font-family: monospace;
}
}
}
.pow-progress {
position: relative;
height: 20px;
background-color: #e8e8e8;
border-radius: 10px;
overflow: hidden;
margin-bottom: 8px;
.progress-bar {
height: 100%;
background-image: linear-gradient(90deg, #409eff, #66b1ff);
transition: width 0.3s;
border-radius: 10px;
}
.progress-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 11px;
color: #333;
font-weight: bold;
}
}
.pow-status {
font-size: 12px;
color: #666;
text-align: center;
}
}
.pow-actions {
.pow-btn {
width: 100%;
height: 35px;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: opacity 0.2s;
&:hover {
opacity: 0.9;
}
&.start-btn {
background-image: linear-gradient(173deg, hsl(38.09deg 91% 57.89%) 0%, hsl(38.09deg 89.38% 71.74%) 100%);
color: #fff;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
}
}
}
@@ -0,0 +1,181 @@
import "./scratch.scss"
import {Dom, CommonCaptcha, destroyEvent} from "../common/common.js"
/**
* 刮刮乐验证码 - 用户需要刮开涂层
*/
const TYPE = "SCRATCH"
function getTemplate(styleConfig) {
return `
<div id="tianai-captcha" class="tianai-captcha-slider tianai-captcha-scratch">
<div class="scratch-tip">
<span id="tianai-captcha-scratch-track-font" style="font-size: ${styleConfig.i18n?.scratch_title_size || '14px'}">${styleConfig.i18n?.scratch_title || '请刮开涂层完成验证'}</span>
<span class="scratch-progress" id="scratch-progress">0%</span>
</div>
<div class="content">
<div class="scratch-container" id="scratch-container">
<img id="tianai-captcha-scratch-bg-img" src="" alt class="scratch-bg-img"/>
<canvas id="tianai-captcha-scratch-canvas" class="scratch-canvas"></canvas>
</div>
<div class="tianai-captcha-tips" id="tianai-captcha-tips"></div>
</div>
<div class="click-confirm-btn">确定</div>
</div>
`;
}
class Scratch extends CommonCaptcha{
constructor(boxEl, styleConfig) {
super();
this.boxEl = boxEl;
this.styleConfig = styleConfig;
this.type = TYPE;
this.currentCaptchaData = {};
this.isDrawing = false;
this.scratchPercentage = 0;
}
init(captchaData, endCallback, loadSuccessCallback) {
// 重载样式
this.destroy();
this.boxEl.append(getTemplate(this.styleConfig));
this.el = this.boxEl.find("#tianai-captcha");
// 载入验证码
this.loadCaptchaForData(this, captchaData);
this.endCallback = endCallback;
if (loadSuccessCallback) {
// 加载成功
loadSuccessCallback(this);
}
return this;
}
initCanvas() {
const canvas = document.getElementById('tianai-captcha-scratch-canvas');
const container = document.getElementById('scratch-container');
if (!canvas || !container) return;
const ctx = canvas.getContext('2d');
const rect = container.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
// 绘制灰色遮罩层
ctx.fillStyle = '#c0c0c0';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 绘制提示文字
ctx.fillStyle = '#999';
ctx.font = '14px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('请用鼠标刮开此区域', canvas.width / 2, canvas.height / 2);
// 绑定刮擦事件
this.bindScratchEvents(canvas, ctx);
}
bindScratchEvents(canvas, ctx) {
const scratch = (e) => {
if (!this.isDrawing) return;
const rect = canvas.getBoundingClientRect();
let x, y;
if (e.touches) {
x = e.touches[0].clientX - rect.left;
y = e.touches[0].clientY - rect.top;
} else {
x = e.offsetX;
y = e.offsetY;
}
ctx.globalCompositeOperation = 'destination-out';
ctx.beginPath();
ctx.arc(x, y, 15, 0, Math.PI * 2);
ctx.fill();
this.calculateScratchPercentage(canvas, ctx);
};
// 鼠标事件
canvas.addEventListener('mousedown', (e) => {
this.isDrawing = true;
this.currentCaptchaData.startTime = new Date();
scratch(e);
});
canvas.addEventListener('mousemove', scratch);
canvas.addEventListener('mouseup', () => {
this.isDrawing = false;
});
canvas.addEventListener('mouseleave', () => {
this.isDrawing = false;
});
// 触摸事件
canvas.addEventListener('touchstart', (e) => {
this.isDrawing = true;
this.currentCaptchaData.startTime = new Date();
e.preventDefault();
scratch(e);
});
canvas.addEventListener('touchmove', (e) => {
e.preventDefault();
scratch(e);
});
canvas.addEventListener('touchend', () => {
this.isDrawing = false;
});
}
calculateScratchPercentage(canvas, ctx) {
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const pixels = imageData.data;
let transparent = 0;
const total = pixels.length / 4;
for (let i = 3; i < pixels.length; i += 4) {
if (pixels[i] === 0) {
transparent++;
}
}
this.scratchPercentage = Math.round((transparent / total) * 100);
const progressEl = this.el.find("#scratch-progress");
progressEl.text(this.scratchPercentage + '%');
// 超过60%自动完成
if (this.scratchPercentage >= 60 && !this.currentCaptchaData.verified) {
this.currentCaptchaData.verified = true;
this.currentCaptchaData.stopTime = new Date();
this.currentCaptchaData.scratchPercentage = this.scratchPercentage;
this.endCallback(this.currentCaptchaData, this);
}
}
destroy () {
const existsCaptchaEl = this.boxEl.children("#tianai-captcha");
if (existsCaptchaEl) {
existsCaptchaEl.remove();
}
destroyEvent();
}
loadCaptchaForData (that, data) {
const bgImg = that.el.find("#tianai-captcha-scratch-bg-img");
bgImg.on("load",() => {
that.currentCaptchaData = {
bgImageWidth: bgImg.width(),
bgImageHeight: bgImg.height(),
startTime: new Date(),
trackList: [],
verified: false,
scratchPercentage: 0
};
that.currentCaptchaData.currentCaptchaId = data.data.id;
// 初始化Canvas
setTimeout(() => that.initCanvas(), 100);
})
bgImg.attr("src", data.data.backgroundImage);
}
}
export default Scratch;
@@ -0,0 +1,73 @@
#tianai-captcha.tianai-captcha-scratch {
box-sizing: border-box;
.scratch-tip {
position: relative;
height: 40px;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
#tianai-captcha-scratch-track-font {
font-size: 14px;
color: #333;
}
.scratch-progress {
font-size: 13px;
color: #409eff;
font-weight: bold;
}
}
.slider-bottom {
position: relative;
top: 6px;
}
.content {
.scratch-container {
width: 100%;
height: 180px;
position: relative;
overflow: hidden;
border-radius: 4px;
.scratch-bg-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.scratch-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
cursor: pointer;
touch-action: none;
}
}
}
.click-confirm-btn {
width: 100%;
height: 35px;
border-radius: 4px;
background-image: linear-gradient(173deg, hsl(38.09deg 91% 57.89%) 0%, hsl(38.09deg 89.38% 71.74%) 100%);
font-size: 15px;
text-align: center;
box-sizing: border-box;
line-height: 35px;
color: #fff;
margin-top: 3px;
cursor: pointer;
transition: opacity 0.2s;
&:hover {
opacity: 0.9;
}
}
}
@@ -0,0 +1,133 @@
import "./word_order_click.scss"
import {Dom, CommonCaptcha, move, initConfig, destroyEvent} from "../common/common.js"
/**
* 语序点选验证码 - 用户需要按顺序点击文字
*/
const TYPE = "WORD_ORDER_CLICK"
function getTemplate(styleConfig) {
return `
<div id="tianai-captcha" class="tianai-captcha-slider tianai-captcha-word-order-click">
<div class="click-tip">
<span id="tianai-captcha-click-track-font" style="font-size: ${styleConfig.i18n?.word_order_click_title_size || '14px'}">${styleConfig.i18n?.word_order_click_title || '请按顺序点击文字'}</span>
<div class="word-order-hint" id="word-order-hint"></div>
</div>
<div class="content">
<div class="bg-img-div">
<img id="tianai-captcha-slider-bg-img" src="" alt/>
<canvas id="tianai-captcha-slider-bg-canvas"></canvas>
<div id="bg-img-click-mask"></div>
</div>
<div class="tianai-captcha-tips" id="tianai-captcha-tips"></div>
</div>
<div class="click-confirm-btn">确定</div>
</div>
`;
}
class WordOrderClick extends CommonCaptcha{
constructor(boxEl, styleConfig) {
super();
this.boxEl = boxEl;
this.styleConfig = styleConfig;
this.type = TYPE;
this.currentCaptchaData = {}
}
init(captchaData, endCallback, loadSuccessCallback) {
// 重载样式
this.destroy();
this.boxEl.append(getTemplate(this.styleConfig));
this.el = this.boxEl.find("#tianai-captcha");
// 载入验证码
this.loadCaptchaForData(this, captchaData);
this.endCallback = endCallback;
const moveFun = move.bind(null, this);
// 初始化点击顺序提示
this.updateOrderHint();
// 绑定事件
this.el.find("#bg-img-click-mask").click((event) => {
if(event.target.className === "click-span") {
return;
}
this.currentCaptchaData.clickCount++;
const trackList = this.currentCaptchaData.trackList;
if (this.currentCaptchaData.clickCount === 1) {
this.currentCaptchaData.startTime = new Date();
// move 轨迹
window.addEventListener("mousemove", moveFun);
this.currentCaptchaData.startX = event.offsetX;
this.currentCaptchaData.startY = event.offsetY;
}
const startTime = this.currentCaptchaData.startTime;
trackList.push({
x: Math.round(event.offsetX),
y: Math.round(event.offsetY),
type: "click",
t: (new Date().getTime() - startTime.getTime())
});
const left = event.offsetX - 12;
const top = event.offsetY - 12;
this.el.find("#bg-img-click-mask").append("<span class='click-span' style='left:" + left + "px;top: " + top + "px'>" + this.currentCaptchaData.clickCount + "</span>")
this.updateOrderHint();
});
// 重新开始按钮
this.el.find(".click-reset-btn").click(() => {
this.currentCaptchaData.clickCount = 0;
this.currentCaptchaData.trackList = [];
this.el.find("#bg-img-click-mask").find(".click-span").remove();
this.updateOrderHint();
});
this.el.find(".click-confirm-btn").click(() => {
if (this.currentCaptchaData.clickCount > 0) {
// 校验
this.currentCaptchaData.stopTime = new Date();
window.removeEventListener("mousemove", moveFun);
this.endCallback(this.currentCaptchaData,this);
}
});
if (loadSuccessCallback) {
// 加载成功
loadSuccessCallback(this);
}
return this;
}
updateOrderHint() {
const hintEl = this.el.find("#word-order-hint");
const count = this.currentCaptchaData.clickCount || 0;
const maxCount = this.currentCaptchaData.maxClickCount || 4;
let html = '';
for (let i = 1; i <= maxCount; i++) {
if (i <= count) {
html += '<span class="hint-dot active">' + i + '</span>';
} else {
html += '<span class="hint-dot">' + i + '</span>';
}
}
hintEl.html(html);
}
destroy () {
const existsCaptchaEl = this.boxEl.children("#tianai-captcha");
if (existsCaptchaEl) {
existsCaptchaEl.remove();
}
destroyEvent();
}
loadCaptchaForData (that, data) {
const bgImg = that.el.find("#tianai-captcha-slider-bg-img");
bgImg.on("load",() => {
that.currentCaptchaData = initConfig(bgImg.width(), bgImg.height(), 0, 0);
that.currentCaptchaData.currentCaptchaId = data.data.id;
that.currentCaptchaData.maxClickCount = data.data.maxClickCount || 4;
})
bgImg.attr("src", data.data.backgroundImage);
}
}
export default WordOrderClick;
@@ -0,0 +1,105 @@
#tianai-captcha.tianai-captcha-word-order-click {
box-sizing: border-box;
.click-tip {
position: relative;
height: 40px;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
#tianai-captcha-click-track-font {
font-size: 14px;
color: #333;
}
.word-order-hint {
display: flex;
gap: 4px;
.hint-dot {
width: 20px;
height: 20px;
border-radius: 50%;
background-color: #e0e0e0;
color: #999;
font-size: 11px;
line-height: 20px;
text-align: center;
transition: all 0.2s;
&.active {
background-color: #409eff;
color: #fff;
}
}
}
}
.slider-bottom {
position: relative;
top: 6px;
}
.content {
#bg-img-click-mask {
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
cursor: pointer;
.click-span {
position: absolute;
left: 0;
top: 0;
border-radius: 50%;
background-color: #409eff;
width: 24px;
height: 24px;
text-align: center;
line-height: 24px;
color: #fff;
border: 2px solid #fff;
box-sizing: content-box;
font-size: 12px;
font-weight: bold;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
}
}
.click-confirm-btn {
width: 100%;
height: 35px;
border-radius: 4px;
background-image: linear-gradient(173deg, hsl(38.09deg 91% 57.89%) 0%, hsl(38.09deg 89.38% 71.74%) 100%);
font-size: 15px;
text-align: center;
box-sizing: border-box;
line-height: 35px;
color: #fff;
margin-top: 3px;
cursor: pointer;
transition: opacity 0.2s;
&:hover {
opacity: 0.9;
}
}
.click-reset-btn {
position: absolute;
right: 0;
top: 0;
font-size: 12px;
color: #409eff;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}