邹景立 de64a2f4a7 fix(login): 优化滑块验证码尺寸计算
根据服务端返回的验证码原始宽高动态计算展示舞台高度,避免固定高度导致不同尺寸背景图被裁切或拉伸。

按背景图实际渲染宽度换算拼图片宽度,并在窗口尺寸变化后重新计算拖动边界,保证滑块位置与缺口比例一致。

取消滑动轨迹填充宽度,减少拖动过程中的视觉干扰,使验证条表现更稳定。
2026-05-20 22:08:20 +08:00

365 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// +----------------------------------------------------------------------
// | Static Plugin for ThinkAdmin
// +----------------------------------------------------------------------
// | 版权所有 2014~2024 ThinkAdmin [ thinkadmin.top ]
// +----------------------------------------------------------------------
// | 官方网站: https://thinkadmin.top
// +----------------------------------------------------------------------
// | 开源协议 ( https://mit-license.org )
// | 免责声明 ( https://thinkadmin.top/disclaimer )
// +----------------------------------------------------------------------
// | gitee 代码仓库https://gitee.com/zoujingli/think-plugs-static
// | github 代码仓库https://github.com/zoujingli/think-plugs-static
// +----------------------------------------------------------------------
$(function () {
window.$body = $('body');
let loginI18n = window.taLoginI18n || {};
function t(key, fallback) {
return typeof loginI18n[key] === 'string' && loginI18n[key].length > 0 ? loginI18n[key] : fallback;
}
/*! 登录界面背景切换 */
$('[data-bg-transition]').each(function (i, el) {
el.idx = 0, el.imgs = [], el.SetBackImage = function (css) {
window.setTimeout(function () {
$(el).removeClass(el.imgs.join(' ')).addClass(css)
}, 1000) && $body.removeClass(el.imgs.join(' ')).addClass(css)
}, el.lazy = window.setInterval(function () {
el.imgs.length > 0 && el.SetBackImage(el.imgs[++el.idx] || el.imgs[el.idx = 0]);
}, 5000) && el.dataset.bgTransition.split(',').forEach(function (image) {
layui.img(image, function (img, cssid, style) {
style = document.createElement('style'), cssid = 'LoginBackImage' + (el.imgs.length + 1);
style.innerHTML = '.' + cssid + '{background-image:url("' + encodeURI(image) + '")!important}';
document.head.appendChild(style) && el.imgs.push(cssid);
});
});
});
let ambientFrame = 0, ambientPoint = {
x: Math.round(window.innerWidth * 0.78),
y: Math.round(window.innerHeight * 0.22)
};
function paintAmbient() {
ambientFrame = 0;
$('.login-container').css({
'--cursor-x': ambientPoint.x + 'px',
'--cursor-y': ambientPoint.y + 'px'
});
}
function queueAmbient(x, y) {
ambientPoint.x = x;
ambientPoint.y = y;
if (ambientFrame) return;
ambientFrame = (window.requestAnimationFrame || window.setTimeout)(paintAmbient, 16);
}
queueAmbient(ambientPoint.x, ambientPoint.y);
$(document).on('mousemove touchmove', function (event) {
let point = event.touches && event.touches[0] ? event.touches[0] : event;
queueAmbient(point.clientX, point.clientY);
});
$(window).on('resize', function () {
queueAmbient(Math.round(window.innerWidth * 0.78), Math.round(window.innerHeight * 0.22));
});
function decodeBase64ToArrayBuffer(value) {
let binary = atob(value), bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
}
function encodeArrayBufferToBase64(buffer) {
let bytes = new Uint8Array(buffer), binary = '';
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary);
}
async function encryptPassword(form, password) {
let publicKey = form.dataset.loginPasswordKey || '';
if (!publicKey || !window.crypto || !window.crypto.subtle || typeof TextEncoder === 'undefined' || window.isSecureContext === false) {
return {mode: 'plain', value: password};
}
try {
// PHP openssl_private_decrypt with OAEP padding only interoperates with the SHA-1 variant here.
let key = await window.crypto.subtle.importKey(
'spki',
decodeBase64ToArrayBuffer(publicKey),
{name: 'RSA-OAEP', hash: 'SHA-1'},
false,
['encrypt']
);
let result = await window.crypto.subtle.encrypt({name: 'RSA-OAEP'}, key, new TextEncoder().encode(password));
return {mode: 'rsa', value: encodeArrayBufferToBase64(result)};
} catch (e) {
return {mode: 'plain', value: password};
}
}
function reloadLoginPage() {
try {
let url = new URL(location.href);
url.hash = '';
url.searchParams.set('_login_reload', String(Date.now()));
location.replace(url.toString());
} catch (e) {
location.href = location.pathname + '?_login_reload=' + Date.now();
}
}
function createLoginSlider(form) {
let $form = $(form), $container = $form.closest('.login-container');
let $popup = $container.find('[data-login-slider-popup]');
let $panel = $popup.find('[data-login-slider-panel]');
if ($panel.length < 1) {
return {
syncError: $.noop,
ensureVerified: function () {
return true;
}
};
}
let $bg = $panel.find('[data-slider-bg]');
let $piece = $panel.find('[data-slider-piece]');
let $stage = $panel.find('.slider-stage');
let $track = $panel.find('[data-slider-track]');
let $message = $panel.find('[data-slider-message]');
let $status = $panel.find('[data-slider-status]');
let $handle = $panel.find('[data-slider-handle]');
let $refresh = $panel.find('[data-slider-refresh]');
let $uniqid = $form.find('[name="uniqid"]');
let $verify = $form.find('[name="verify"]');
let $mode = $form.find('[name="password_mode"]');
let request = form.dataset.loginSlider || '';
let check = form.dataset.loginCheck || '';
let state = {
bgHeight: 0,
bgWidth: 0,
currentLeft: 0,
dragging: false,
loaded: false,
maxLeft: 0,
originX: 0,
pieceWidth: 100,
required: false,
sourceHeight: 300,
sourceWidth: 600,
startLeft: 0,
verified: false,
working: false,
};
function setStatus(text, type) {
$panel.removeClass('is-error is-success');
type && $panel.addClass(type);
$message.text(text);
$status.text(text);
}
function setPosition(left) {
state.currentLeft = Math.max(0, Math.min(left, state.maxLeft));
$handle.css('left', state.currentLeft + 'px');
$track.css('width', '0px');
$piece.css('left', state.currentLeft + 'px');
}
function recalculate() {
let sourceWidth = Math.max(parseInt(state.sourceWidth, 10) || 600, 1);
let sourceHeight = Math.max(parseInt(state.sourceHeight, 10) || 300, 1);
let pieceWidth = Math.max(parseInt(state.pieceWidth, 10) || 100, 1);
state.bgWidth = $stage.innerWidth();
if (state.bgWidth > 0) {
$stage.css('height', Math.round(state.bgWidth * sourceHeight / sourceWidth) + 'px');
}
state.bgWidth = $stage.innerWidth();
state.bgHeight = $stage.innerHeight();
state.maxLeft = Math.max(state.bgWidth - $handle.outerWidth(), 0);
$piece.css('width', (pieceWidth / sourceWidth * state.bgWidth) + 'px');
setPosition(state.currentLeft);
}
function resetChallenge() {
state.verified = false;
state.working = false;
$uniqid.val('');
$verify.val('');
$panel.removeClass('is-error is-success');
setPosition(0);
setStatus(t('dragToVerify', '请按住滑块,拖动完成验证'));
}
function hideChallenge() {
$popup.removeClass('is-visible');
$body.removeClass('login-verify-active');
window.setTimeout(function () {
if (!$popup.hasClass('is-visible')) {
$popup.addClass('layui-hide');
}
}, 220);
}
function loadChallenge() {
if (request.length < 5) return $.msg.tips(t('sliderApiMissing', '请设置滑块验证接口'));
resetChallenge();
let handleChallenge = function (ret) {
if (parseInt(ret.code, 10) !== 200) {
ret.data && ret.data.reload && reloadLoginPage();
return false;
}
state.sourceWidth = parseInt(ret.data.width || 600) || 600;
state.sourceHeight = parseInt(ret.data.height || 300) || 300;
state.pieceWidth = parseInt(ret.data.piece_width || 100) || 100;
state.loaded = true;
$uniqid.val(ret.data.uniqid || '');
$bg.attr('src', ret.data.bgimg || '');
$piece.attr('src', ret.data.water || '');
(window.requestAnimationFrame || window.setTimeout)(recalculate, 0);
setStatus(t('dragToVerify', '请按住滑块,拖动完成验证'));
return false;
};
handleChallenge.allowHttpError = true;
$.form.load(request, {token: form.dataset.loginToken || ''}, 'post', handleChallenge, false);
}
function showChallenge(refresh) {
state.required = true;
$popup.removeClass('layui-hide');
$body.addClass('login-verify-active');
(window.requestAnimationFrame || window.setTimeout)(function () {
$popup.addClass('is-visible');
recalculate();
}, 0);
if (refresh || !$uniqid.val()) loadChallenge();
}
function verifyCurrentPosition() {
if (state.working || !$uniqid.val() || check.length < 5) return;
state.working = true;
setStatus(t('verifying', '正在校验...'));
$.form.load(check, {
uniqid: $uniqid.val(),
verify: Math.round(state.currentLeft * state.sourceWidth / Math.max(state.bgWidth, 1))
}, 'post', function (ret) {
state.working = false;
let value = Math.round(state.currentLeft * state.sourceWidth / Math.max(state.bgWidth, 1));
let result = parseInt(ret.data && ret.data.state || -1);
if (result === 1) {
state.verified = true;
state.required = false;
$verify.val(String(value));
$panel.removeClass('is-error').addClass('is-success');
$message.text(t('verifyPassedContinue', '验证通过,请继续登录'));
$status.text(t('sliderVerified', '滑块验证通过'));
window.setTimeout(hideChallenge, 260);
} else if (result === 0) {
state.verified = false;
state.required = true;
$verify.val('');
$panel.removeClass('is-success').addClass('is-error');
$message.text(t('wrongPositionRetry', '位置不正确,请重试'));
$status.text(t('wrongPositionRetry', '位置不正确,请重试'));
window.setTimeout(function () {
if (!state.verified) {
$panel.removeClass('is-error');
setPosition(0);
setStatus(t('dragToVerify', '请按住滑块,拖动完成验证'));
}
}, 500);
} else {
loadChallenge();
}
return false;
}, false);
}
function getPoint(event) {
return event.touches && event.touches[0] ? event.touches[0] : event;
}
function startDrag(event) {
if (state.working || state.verified || !$uniqid.val()) return;
let point = getPoint(event);
state.dragging = true;
state.originX = point.clientX;
state.startLeft = state.currentLeft;
$handle.addClass('is-active');
event.preventDefault();
}
function moveDrag(event) {
if (!state.dragging) return;
let point = getPoint(event);
setPosition(state.startLeft + point.clientX - state.originX);
event.preventDefault();
}
function endDrag() {
if (!state.dragging) return;
state.dragging = false;
$handle.removeClass('is-active');
verifyCurrentPosition();
}
$bg.on('load', recalculate);
$(window).on('resize', recalculate);
$handle.on('mousedown touchstart', startDrag);
$(document).on('mousemove touchmove', moveDrag);
$(document).on('mouseup touchend touchcancel', endDrag);
$refresh.on('click', function () {
showChallenge(true);
});
return {
syncError: function (data) {
if (data && data.need_verify) {
state.verified = false;
state.required = true;
showChallenge(!!data.refresh_verify);
}
},
ensureVerified: function () {
if (state.required && !state.verified) {
showChallenge(false);
$.msg.tips(t('needVerifyFirst', '请先完成滑块验证'));
return false;
}
$mode.val('plain');
return true;
},
setPasswordMode: function (mode) {
$mode.val(mode);
}
};
}
/*! 后台登录提交处理 */
$body.find('form[data-login-form]').each(function (idx, form) {
let slider = createLoginSlider(form);
$(form).vali(function (data) {
if (!slider.ensureVerified()) return false;
encryptPassword(form, data.password || '').then(function (cipher) {
let payload = $.extend({}, data, {password: cipher.value, password_mode: cipher.mode});
slider.setPasswordMode(cipher.mode);
let handleSubmit = function (ret) {
if (parseInt(ret.code, 10) !== 200) {
if (ret.data && ret.data.reload) {
reloadLoginPage();
return false;
}
slider.syncError(ret.data || {});
return false;
}
};
handleSubmit.allowHttpError = true;
$.form.load(location.href, payload, "post", handleSubmit, null, null, 'false');
});
});
});
});