374 lines
13 KiB
JavaScript
374 lines
13 KiB
JavaScript
class SliderCaptcha {
|
|
constructor(container, options = {}) {
|
|
this.container = typeof container === 'string' ? document.querySelector(container) : container;
|
|
|
|
if (!this.container) {
|
|
throw new Error('SliderCaptcha: Container element not found');
|
|
}
|
|
|
|
this.options = {
|
|
apiBaseUrl: '/user',
|
|
onSuccess: null,
|
|
onFail: null,
|
|
onRefresh: null,
|
|
width: 300,
|
|
autoLoad: true,
|
|
...options
|
|
};
|
|
|
|
this.captchaKey = null;
|
|
this.bgImage = null;
|
|
this.sliderImage = null;
|
|
this.yPosition = 0;
|
|
this.isDragging = false;
|
|
this.startX = 0;
|
|
this.currentX = 0;
|
|
this.trajectory = [];
|
|
this.maxX = 0;
|
|
this.verified = false;
|
|
|
|
this._init();
|
|
}
|
|
|
|
_init() {
|
|
this._createDOM();
|
|
this._bindEvents();
|
|
|
|
if (this.options.autoLoad) {
|
|
this.loadCaptcha();
|
|
}
|
|
}
|
|
|
|
_createDOM() {
|
|
const captchaId = 'slider-captcha-' + Date.now();
|
|
this.container.innerHTML = `
|
|
<div class="slider-captcha" id="${captchaId}">
|
|
<div class="slider-captcha-header">
|
|
<span class="slider-captcha-title">请完成安全验证</span>
|
|
<button class="slider-captcha-refresh" type="button" title="刷新验证码">
|
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
|
<path d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="slider-captcha-body">
|
|
<div class="slider-captcha-image-container">
|
|
<canvas class="slider-captcha-canvas" width="300" height="150"></canvas>
|
|
<div class="slider-captcha-slider-piece"></div>
|
|
<div class="slider-captcha-loading">
|
|
<div class="slider-captcha-spinner"></div>
|
|
<span>加载中...</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="slider-captcha-track-container">
|
|
<div class="slider-captcha-track">
|
|
<div class="slider-captcha-track-text">向右拖动滑块完成验证</div>
|
|
<div class="slider-captcha-track-fill"></div>
|
|
</div>
|
|
<div class="slider-captcha-slider-btn" role="slider" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" tabindex="0">
|
|
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
|
<path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="slider-captcha-footer">
|
|
<div class="slider-captcha-status"></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
this.element = this.container.querySelector('.slider-captcha');
|
|
this.canvas = this.container.querySelector('.slider-captcha-canvas');
|
|
this.ctx = this.canvas.getContext('2d');
|
|
this.sliderPiece = this.container.querySelector('.slider-captcha-slider-piece');
|
|
this.loading = this.container.querySelector('.slider-captcha-loading');
|
|
this.track = this.container.querySelector('.slider-captcha-track');
|
|
this.trackFill = this.container.querySelector('.slider-captcha-track-fill');
|
|
this.trackText = this.container.querySelector('.slider-captcha-track-text');
|
|
this.sliderBtn = this.container.querySelector('.slider-captcha-slider-btn');
|
|
this.refreshBtn = this.container.querySelector('.slider-captcha-refresh');
|
|
this.status = this.container.querySelector('.slider-captcha-status');
|
|
|
|
this.maxX = this.track.offsetWidth - this.sliderBtn.offsetWidth;
|
|
}
|
|
|
|
_bindEvents() {
|
|
this.refreshBtn.addEventListener('click', () => this.loadCaptcha());
|
|
|
|
this.sliderBtn.addEventListener('mousedown', (e) => this._onDragStart(e));
|
|
this.sliderBtn.addEventListener('touchstart', (e) => this._onDragStart(e), { passive: false });
|
|
|
|
document.addEventListener('mousemove', (e) => this._onDragMove(e));
|
|
document.addEventListener('touchmove', (e) => this._onDragMove(e), { passive: false });
|
|
|
|
document.addEventListener('mouseup', (e) => this._onDragEnd(e));
|
|
document.addEventListener('touchend', (e) => this._onDragEnd(e));
|
|
}
|
|
|
|
async loadCaptcha() {
|
|
this._showLoading(true);
|
|
this._resetState();
|
|
|
|
try {
|
|
const response = await fetch(`${this.options.apiBaseUrl}/slider-captcha/generate/`);
|
|
const data = await response.json();
|
|
|
|
if (data.code === 0 || data.code === 20000) {
|
|
const captchaData = data.data;
|
|
this.captchaKey = captchaData.captcha_key;
|
|
this.yPosition = captchaData.y_position;
|
|
|
|
await this._loadImages(captchaData.bg_image, captchaData.slider_image);
|
|
this._drawCaptcha();
|
|
this._showLoading(false);
|
|
} else {
|
|
throw new Error(data.message || '加载验证码失败');
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load captcha:', error);
|
|
this._showLoading(false);
|
|
this._showStatus('加载失败,点击刷新重试', 'fail');
|
|
}
|
|
}
|
|
|
|
_loadImages(bgSrc, sliderSrc) {
|
|
return new Promise((resolve, reject) => {
|
|
let loaded = 0;
|
|
const onLoad = () => {
|
|
loaded++;
|
|
if (loaded === 2) resolve();
|
|
};
|
|
|
|
this.bgImage = new Image();
|
|
this.bgImage.onload = onLoad;
|
|
this.bgImage.onerror = reject;
|
|
this.bgImage.src = bgSrc;
|
|
|
|
this.sliderImage = new Image();
|
|
this.sliderImage.onload = onLoad;
|
|
this.sliderImage.onerror = reject;
|
|
this.sliderImage.src = sliderSrc;
|
|
});
|
|
}
|
|
|
|
_drawCaptcha() {
|
|
if (!this.bgImage || !this.ctx) return;
|
|
|
|
this.canvas.width = this.options.width;
|
|
this.canvas.height = 150;
|
|
|
|
this.ctx.drawImage(this.bgImage, 0, 0, this.canvas.width, this.canvas.height);
|
|
|
|
if (this.sliderImage) {
|
|
this.sliderPiece.style.backgroundImage = `url(${this.sliderImage.src})`;
|
|
this.sliderPiece.style.top = `${this.yPosition}px`;
|
|
this.sliderPiece.style.left = '0px';
|
|
this.sliderPiece.style.width = '60px';
|
|
this.sliderPiece.style.height = '60px';
|
|
}
|
|
}
|
|
|
|
_onDragStart(e) {
|
|
if (this.verified) return;
|
|
|
|
e.preventDefault();
|
|
this.isDragging = true;
|
|
this.trajectory = [];
|
|
|
|
const clientX = e.type === 'touchstart' ? e.touches[0].clientX : e.clientX;
|
|
this.startX = clientX;
|
|
this.currentX = 0;
|
|
|
|
this.sliderBtn.classList.add('dragging');
|
|
this.element.classList.add('dragging');
|
|
|
|
this._recordTrajectory(0);
|
|
}
|
|
|
|
_onDragMove(e) {
|
|
if (!this.isDragging) return;
|
|
|
|
e.preventDefault();
|
|
|
|
const clientX = e.type === 'touchmove' ? e.touches[0].clientX : e.clientX;
|
|
let moveX = clientX - this.startX;
|
|
|
|
moveX = Math.max(0, Math.min(moveX, this.maxX));
|
|
this.currentX = moveX;
|
|
|
|
this._updateSliderPosition(moveX);
|
|
this._recordTrajectory(moveX);
|
|
}
|
|
|
|
_onDragEnd(e) {
|
|
if (!this.isDragging) return;
|
|
|
|
this.isDragging = false;
|
|
this.sliderBtn.classList.remove('dragging');
|
|
this.element.classList.remove('dragging');
|
|
|
|
if (this.currentX > 10) {
|
|
this._verify();
|
|
} else {
|
|
this._resetSlider();
|
|
}
|
|
}
|
|
|
|
_updateSliderPosition(x) {
|
|
const percent = (x / this.maxX) * 100;
|
|
|
|
this.sliderBtn.style.left = `${x}px`;
|
|
this.sliderBtn.setAttribute('aria-valuenow', Math.round(percent));
|
|
|
|
this.trackFill.style.width = `${x + this.sliderBtn.offsetWidth / 2}px`;
|
|
|
|
this.sliderPiece.style.left = `${x}px`;
|
|
}
|
|
|
|
_recordTrajectory(x) {
|
|
this.trajectory.push({
|
|
x: Math.round(x),
|
|
t: Date.now()
|
|
});
|
|
}
|
|
|
|
async _verify() {
|
|
const xPosition = Math.round(this.currentX);
|
|
|
|
try {
|
|
const response = await fetch(`${this.options.apiBaseUrl}/slider-captcha/verify/`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
captcha_key: this.captchaKey,
|
|
x_position: xPosition,
|
|
trajectory: this.trajectory
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.code === 0 || data.code === 20000) {
|
|
if (data.data && data.data.verified) {
|
|
this._onSuccess();
|
|
} else {
|
|
this._onFail();
|
|
}
|
|
} else {
|
|
this._onFail(data.message);
|
|
}
|
|
} catch (error) {
|
|
console.error('Verification failed:', error);
|
|
this._onFail('验证失败,请重试');
|
|
}
|
|
}
|
|
|
|
_onSuccess() {
|
|
this.verified = true;
|
|
|
|
this.sliderBtn.classList.add('success');
|
|
this.element.classList.add('verified');
|
|
|
|
this.trackFill.style.background = 'linear-gradient(90deg, #52c41a, #73d13d)';
|
|
|
|
this._showStatus('验证成功', 'success');
|
|
|
|
if (typeof this.options.onSuccess === 'function') {
|
|
this.options.onSuccess({
|
|
captchaKey: this.captchaKey,
|
|
xPosition: Math.round(this.currentX)
|
|
});
|
|
}
|
|
}
|
|
|
|
_onFail(message = '验证失败,请重试') {
|
|
this.sliderBtn.classList.add('fail');
|
|
this._showStatus(message, 'fail');
|
|
|
|
setTimeout(() => {
|
|
this._resetSlider();
|
|
this.sliderBtn.classList.remove('fail');
|
|
this.loadCaptcha();
|
|
}, 1500);
|
|
|
|
if (typeof this.options.onFail === 'function') {
|
|
this.options.onFail(message);
|
|
}
|
|
}
|
|
|
|
_resetState() {
|
|
this.verified = false;
|
|
this.isDragging = false;
|
|
this.currentX = 0;
|
|
this.trajectory = [];
|
|
|
|
this.element.classList.remove('verified', 'dragging');
|
|
this.sliderBtn.classList.remove('success', 'fail', 'dragging');
|
|
this.sliderBtn.style.left = '0px';
|
|
this.sliderBtn.setAttribute('aria-valuenow', 0);
|
|
this.trackFill.style.width = '0px';
|
|
this.trackFill.style.background = 'linear-gradient(90deg, #52c41a, #73d13d)';
|
|
this.trackText.style.display = 'block';
|
|
this._showStatus('', '');
|
|
}
|
|
|
|
_resetSlider() {
|
|
this.currentX = 0;
|
|
this.trajectory = [];
|
|
|
|
this.sliderBtn.style.left = '0px';
|
|
this.sliderBtn.setAttribute('aria-valuenow', 0);
|
|
this.trackFill.style.width = '0px';
|
|
|
|
this.sliderPiece.style.left = '0px';
|
|
|
|
this._showStatus('', '');
|
|
}
|
|
|
|
_showLoading(show) {
|
|
if (this.loading) {
|
|
this.loading.classList.toggle('hidden', !show);
|
|
}
|
|
}
|
|
|
|
_showStatus(message, type) {
|
|
if (this.status) {
|
|
this.status.textContent = message;
|
|
this.status.className = 'slider-captcha-status';
|
|
if (type) {
|
|
this.status.classList.add(type);
|
|
}
|
|
if (!message) {
|
|
this.status.classList.add('hidden');
|
|
}
|
|
}
|
|
}
|
|
|
|
refresh() {
|
|
this.loadCaptcha();
|
|
}
|
|
|
|
destroy() {
|
|
document.removeEventListener('mousemove', this._onDragMove);
|
|
document.removeEventListener('touchmove', this._onDragMove);
|
|
document.removeEventListener('mouseup', this._onDragEnd);
|
|
document.removeEventListener('touchend', this._onDragEnd);
|
|
|
|
this.container.innerHTML = '';
|
|
}
|
|
|
|
isVerified() {
|
|
return this.verified;
|
|
}
|
|
}
|
|
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
module.exports = SliderCaptcha;
|
|
}
|