Files
2026-08-05 23:59:15 +08:00

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;
}