feat: 增强版验证码全套实现 — 16种类型生成器/加密/风控/站点管理/平台后端/前端SDK/UI
- 核心新增: ICON_CLICK(PNG图标方案,预渲染资源)/SCRATCH/JIGSAW/CURVE_SLIDER(V1-V3)/ANGLE/CURVE_DRAW/WORD_ORDER_CLICK/PROOF_OF_WORK 等生成器 - 图标点选: classpath PNG 加载替代运行时字体渲染(Linux容器无emoji字体),提示条图作为 templateImage 返回前端 - 新增模块: crypto(AES+RSA)/obfuscator(背景乱序/噪声/扭曲)/risk(风控/IP黑名单/限流)/site(站点管理)/ml(轨迹规则引擎) - 平台后端: 站点管理/验证码API(generate/verify/secondary-verify)/统计/ML轨迹学习 - 前端SDK: TPCaptcha兼容,支持全部新型号渲染与交互 - 工具: tools/icon-render 图标预渲染工具
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" width="100%" height="100%">
|
||||
<defs>
|
||||
<!-- 渐变色定义 -->
|
||||
<linearGradient id="shieldGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#4F46E5" /> <!-- 科技靛蓝 -->
|
||||
<stop offset="100%" stop-color="#06B6D4" /> <!-- 灵动青色 -->
|
||||
</linearGradient>
|
||||
<linearGradient id="ringGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#10B981" /> <!-- 安全绿 -->
|
||||
<stop offset="100%" stop-color="#3B82F6" /> <!-- 信任蓝 -->
|
||||
</linearGradient>
|
||||
<!-- 发光滤镜 -->
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="2" result="blur" />
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- 外部盾牌形状 (象征安全防护) -->
|
||||
<path d="M 50 10 L 85 25 L 85 50 C 85 75 50 90 50 90 C 50 90 15 75 15 50 L 15 25 Z"
|
||||
fill="none"
|
||||
stroke="url(#shieldGrad)"
|
||||
stroke-width="4"
|
||||
stroke-linejoin="round" />
|
||||
|
||||
<!-- 内部动态扫描环 (象征行为分析与智能验证) -->
|
||||
<circle cx="50" cy="50" r="16"
|
||||
fill="none"
|
||||
stroke="url(#ringGrad)"
|
||||
stroke-width="4"
|
||||
stroke-dasharray="60 40"
|
||||
stroke-linecap="round"
|
||||
filter="url(#glow)">
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 50 50" to="360 50 50" dur="1.5s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
|
||||
<!-- 中心脉冲核心 (象征触控/鼠标焦点) -->
|
||||
<circle cx="50" cy="50" r="4" fill="#06B6D4">
|
||||
<animate attributeName="r" values="3; 6; 3" dur="1.5s" repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="0.4; 1; 0.4" dur="1.5s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
|
||||
<!-- 上下装饰轨迹线 (象征运动轨迹) -->
|
||||
<path d="M 35 38 Q 50 28 65 38" fill="none" stroke="#4F46E5" stroke-width="2" stroke-linecap="round" opacity="0.6">
|
||||
<animate attributeName="opacity" values="0.2; 0.8; 0.2" dur="2s" repeatCount="indefinite" />
|
||||
</path>
|
||||
<path d="M 35 62 Q 50 72 65 62" fill="none" stroke="#4F46E5" stroke-width="2" stroke-linecap="round" opacity="0.6">
|
||||
<animate attributeName="opacity" values="0.8; 0.2; 0.8" dur="2s" repeatCount="indefinite" />
|
||||
</path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,746 @@
|
||||
/**
|
||||
* tianai-captcha-enhanced SDK
|
||||
* 兼容 TPCaptcha API (TPCaptcha.init / onSuccess / verifyToken)
|
||||
*
|
||||
* Usage:
|
||||
* TACaptcha.init({
|
||||
* elId: 'captcha-box',
|
||||
* siteKey: 'your-site-key',
|
||||
* type: 'SLIDER', // optional
|
||||
* scene: 'login', // optional
|
||||
* serverUrl: 'https://your-server/api', // optional
|
||||
* onSuccess: (result) => { console.log(result.data.verifyToken); },
|
||||
* onFail: () => { console.log('failed'); }
|
||||
* });
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
const INSTANCES = new Map();
|
||||
|
||||
class TACaptchaInstance {
|
||||
constructor(options) {
|
||||
this.options = Object.assign({
|
||||
elId: null,
|
||||
siteKey: '',
|
||||
mode: 'click',
|
||||
type: null,
|
||||
scene: 'default',
|
||||
serverUrl: '/api',
|
||||
onSuccess: null,
|
||||
onFail: null,
|
||||
onOpen: null,
|
||||
onClose: null,
|
||||
onRefresh: null,
|
||||
}, options);
|
||||
|
||||
this.container = null;
|
||||
this.modal = null;
|
||||
this.captchaData = null;
|
||||
this.verifyToken = null;
|
||||
this.isDragging = false;
|
||||
this.startX = 0;
|
||||
this.currentX = 0;
|
||||
this.trackList = [];
|
||||
this.startTime = 0;
|
||||
this.isDark = this._detectTheme();
|
||||
this._themeObserver = null;
|
||||
|
||||
this._init();
|
||||
}
|
||||
|
||||
_detectTheme() {
|
||||
if (this.options.theme === 'dark') return true;
|
||||
if (this.options.theme === 'light') return false;
|
||||
if (document.body.classList.contains('dark') || document.documentElement.classList.contains('dark')) return true;
|
||||
if (document.documentElement.getAttribute('data-theme') === 'dark') return true;
|
||||
if (document.documentElement.getAttribute('color-scheme') === 'dark') return true;
|
||||
if (document.documentElement.style.colorScheme === 'dark') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
_watchTheme() {
|
||||
if (this._themeObserver) this._themeObserver.disconnect();
|
||||
const targets = [document.body, document.documentElement];
|
||||
this._themeObserver = new MutationObserver(() => {
|
||||
const newDark = this._detectTheme();
|
||||
if (newDark !== this.isDark) {
|
||||
this.isDark = newDark;
|
||||
this._createTriggerButton();
|
||||
}
|
||||
});
|
||||
targets.forEach(t => {
|
||||
if (t) this._themeObserver.observe(t, { attributes: true, attributeFilter: ['class', 'data-theme', 'color-scheme'] });
|
||||
});
|
||||
if (window.matchMedia) {
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
this._mqHandler = () => {
|
||||
const newDark = this._detectTheme();
|
||||
if (newDark !== this.isDark) {
|
||||
this.isDark = newDark;
|
||||
this._createTriggerButton();
|
||||
}
|
||||
};
|
||||
mq.addEventListener('change', this._mqHandler);
|
||||
}
|
||||
}
|
||||
|
||||
_init() {
|
||||
if (this.options.mode === 'event') {
|
||||
this._createModal();
|
||||
return;
|
||||
}
|
||||
this._createTriggerButton();
|
||||
this._createModal();
|
||||
this._watchTheme();
|
||||
}
|
||||
|
||||
_createTriggerButton() {
|
||||
const el = document.getElementById(this.options.elId);
|
||||
if (!el) return;
|
||||
this.container = el;
|
||||
|
||||
const typeLabel = this.options.type || '智能验证';
|
||||
|
||||
// 注入呼吸灯 + 盾牌动画样式
|
||||
if (!document.getElementById('tacaptcha-breathing-style')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'tacaptcha-breathing-style';
|
||||
style.textContent =
|
||||
'@keyframes tacaptcha-breathe{0%,100%{box-shadow:0 0 0 0 rgba(82,196,26,0.4)}50%{box-shadow:0 0 0 6px rgba(82,196,26,0)}}' +
|
||||
'.tacaptcha-dot{width:8px;height:8px;border-radius:50%;background:#52c41a;animation:tacaptcha-breathe 2s ease-in-out infinite}' +
|
||||
'.tacaptcha-shield .shield-shard{transform-origin:50px 50px;transition:transform 2s cubic-bezier(0.22,1,0.36,1),fill 2s ease,opacity 2s ease}' +
|
||||
'.tacaptcha-shield .ward-group{transition:opacity 2s ease}' +
|
||||
'.tacaptcha-shield .ward-pulse{transform-origin:50px 50px;animation:pulse-ward 2s cubic-bezier(0.1,0.7,0.3,1) infinite}' +
|
||||
'.tacaptcha-shield .ward-pulse.delay{animation-delay:1s}' +
|
||||
'@keyframes pulse-ward{0%{transform:scale(0.6);opacity:1;stroke-width:6px}100%{transform:scale(1.8);opacity:0;stroke-width:1px}}' +
|
||||
'.tacaptcha-shield .sword-slide{transform:translateY(-85px);opacity:0;transition:transform 2s cubic-bezier(0.175,0.885,0.32,1.4),opacity 2s ease}' +
|
||||
'.tacaptcha-shield .sword-anchor-a{transform-origin:50px 50px;transform:rotate(-45deg)}' +
|
||||
'.tacaptcha-shield .sword-anchor-b{transform-origin:50px 50px;transform:rotate(45deg)}' +
|
||||
'.tacaptcha-shield.state-wait .ward-group{opacity:1}' +
|
||||
'.tacaptcha-shield.state-success .ward-group,.tacaptcha-shield.state-fail .ward-group{opacity:0}' +
|
||||
'.tacaptcha-shield.state-success .ward-pulse,.tacaptcha-shield.state-fail .ward-pulse{animation:none}' +
|
||||
'.tacaptcha-shield.state-wait .shield-shard{fill:url(#tacaptcha-mb);transform:translate(0,0) scale(1) rotate(0deg);opacity:1;filter:drop-shadow(0 6px 8px rgba(0,0,0,0.6))}' +
|
||||
'.tacaptcha-shield.state-success .shield-shard{fill:url(#tacaptcha-mg);transform:translate(0,0) scale(1) rotate(0deg);opacity:1;filter:drop-shadow(0 6px 8px rgba(0,0,0,0.6))}' +
|
||||
'.tacaptcha-shield.state-success .sword-slide{transform:translateY(-15px);opacity:1}' +
|
||||
'.tacaptcha-shield.state-fail .shield-shard{fill:url(#tacaptcha-mr);opacity:0.9;filter:none}' +
|
||||
'.tacaptcha-shield.state-fail .shard-tl{transform:translate(-25px,-30px) scale(0.9) rotateX(30deg) rotateY(-20deg) rotateZ(-15deg);opacity:0.7}' +
|
||||
'.tacaptcha-shield.state-fail .shard-tr{transform:translate(25px,-30px) scale(0.9) rotateX(30deg) rotateY(20deg) rotateZ(15deg);opacity:0.7}' +
|
||||
'.tacaptcha-shield.state-fail .shard-b{transform:translate(0,35px) scale(0.8) rotateX(-20deg) rotateZ(5deg);opacity:0.5}';
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
const bgColor = this.isDark ? '#1a1a2e' : '#fff';
|
||||
const borderColor = this.isDark ? '#2a2a4a' : '#e8e8e8';
|
||||
const textColor = this.isDark ? '#e0e0e0' : '#333';
|
||||
const subColor = this.isDark ? '#888' : '#999';
|
||||
const btnBg = this.isDark ? '#16213e' : '#fafafa';
|
||||
const btnBorder = this.isDark ? '#0f3460' : '#d9d9d9';
|
||||
const btnColor = this.isDark ? '#aaa' : '#666';
|
||||
const shadow = this.isDark ? '0 2px 8px rgba(0,0,0,0.3)' : '0 1px 4px rgba(0,0,0,0.06)';
|
||||
|
||||
const shieldSvg = this._createShieldSvg('state-wait');
|
||||
|
||||
const btn = document.createElement('div');
|
||||
btn.style.cssText = 'display:flex;align-items:center;gap:10px;padding:12px 16px;border:1px solid ' + borderColor + ';border-radius:8px;cursor:pointer;user-select:none;transition:all 0.2s;font-size:14px;color:' + textColor + ';background:' + bgColor + ';box-shadow:' + shadow + ';';
|
||||
btn.innerHTML =
|
||||
shieldSvg +
|
||||
'<span style="flex:1;color:' + textColor + ';white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">点击进行验证</span>' +
|
||||
'<span style="color:' + subColor + ';font-size:12px;white-space:nowrap;flex-shrink:0;">' + typeLabel + '</span>' +
|
||||
'<span style="display:inline-flex;align-items:center;gap:5px;padding:4px 12px;border:1px solid ' + btnBorder + ';border-radius:4px;font-size:12px;color:' + btnColor + ';background:' + btnBg + ';">' +
|
||||
'<span class="tacaptcha-dot"></span>点击验证</span>';
|
||||
|
||||
btn.addEventListener('mouseenter', () => { btn.style.borderColor = '#1890ff'; });
|
||||
btn.addEventListener('mouseleave', () => { btn.style.borderColor = borderColor; });
|
||||
btn.addEventListener('click', () => this._openCaptcha());
|
||||
|
||||
el.appendChild(btn);
|
||||
this.triggerBtn = btn;
|
||||
}
|
||||
|
||||
_createShieldSvg(stateClass) {
|
||||
return '<svg class="tacaptcha-shield ' + stateClass + '" width="32" height="32" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" style="flex-shrink:0;">' +
|
||||
'<defs>' +
|
||||
'<linearGradient id="tacaptcha-mb" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#3B82F6"/><stop offset="50%" stop-color="#1E40AF"/><stop offset="100%" stop-color="#60A5FA"/></linearGradient>' +
|
||||
'<linearGradient id="tacaptcha-mg" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#10B981"/><stop offset="50%" stop-color="#047857"/><stop offset="100%" stop-color="#34D399"/></linearGradient>' +
|
||||
'<linearGradient id="tacaptcha-mr" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#EF4444"/><stop offset="50%" stop-color="#B91C1C"/><stop offset="100%" stop-color="#F87171"/></linearGradient>' +
|
||||
'<linearGradient id="tacaptcha-bl" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="#f8fafc"/><stop offset="100%" stop-color="#94a3b8"/></linearGradient>' +
|
||||
'<linearGradient id="tacaptcha-bd" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="#64748b"/><stop offset="100%" stop-color="#334155"/></linearGradient>' +
|
||||
'<linearGradient id="tacaptcha-gold" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#FDE047"/><stop offset="50%" stop-color="#EAB308"/><stop offset="100%" stop-color="#A16207"/></linearGradient>' +
|
||||
'<filter id="tacaptcha-ss"><feDropShadow dx="2" dy="3" stdDeviation="1.5" flood-color="#000" flood-opacity="0.5"/></filter>' +
|
||||
'<filter id="tacaptcha-mg2"><feGaussianBlur stdDeviation="2" result="blur"/><feComposite in="SourceGraphic" in2="blur" operator="over"/></filter>' +
|
||||
'<filter id="tacaptcha-emg" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur in="SourceGraphic" stdDeviation="2" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter>' +
|
||||
'<g id="tacaptcha-sw" filter="url(#tacaptcha-ss)"><polygon points="43,26 50,26 50,115" fill="url(#tacaptcha-bl)"/><polygon points="50,26 57,26 50,115" fill="url(#tacaptcha-bd)"/><rect x="45" y="4" width="10" height="22" fill="#1e293b"/><polygon points="20,22 50,26 80,22 80,27 50,32 20,27" fill="#713F12"/><polygon points="20,22 50,26 80,22 50,24" fill="url(#tacaptcha-gold)"/><polygon points="50,-6 57,0 50,6 43,0" fill="url(#tacaptcha-gold)"/></g>' +
|
||||
'</defs>' +
|
||||
'<g class="ward-group" filter="url(#tacaptcha-emg)"><circle class="ward-pulse" cx="50" cy="50" r="35" fill="none" stroke="#38BDF8" stroke-linecap="round"/><circle class="ward-pulse delay" cx="50" cy="50" r="35" fill="none" stroke="#38BDF8" stroke-linecap="round"/></g>' +
|
||||
'<g class="shield-main-group"><path class="shield-shard shard-tl" d="M 50 12 L 18 28 L 18 52 L 50 48 Z" fill="url(#tacaptcha-mb)"/><path class="shield-shard shard-tr" d="M 50 12 L 82 28 L 82 52 L 50 48 Z" fill="url(#tacaptcha-mb)"/><path class="shield-shard shard-b" d="M 18 52 C 18 78 50 92 50 92 C 50 92 82 78 82 52 L 50 48 Z" fill="url(#tacaptcha-mb)"/></g>' +
|
||||
'<g class="sword-anchor-a"><g class="sword-slide"><use href="#tacaptcha-sw"/></g></g>' +
|
||||
'<g class="sword-anchor-b"><g class="sword-slide"><use href="#tacaptcha-sw"/></g></g>' +
|
||||
'</svg>';
|
||||
}
|
||||
|
||||
_setShieldState(state) {
|
||||
if (!this.triggerBtn) return;
|
||||
const svg = this.triggerBtn.querySelector('.tacaptcha-shield');
|
||||
if (svg) {
|
||||
svg.classList.remove('state-wait', 'state-success', 'state-fail');
|
||||
const pulses = svg.querySelectorAll('.ward-pulse');
|
||||
pulses.forEach(p => { p.style.animation = 'none'; });
|
||||
void svg.offsetWidth;
|
||||
svg.classList.add('state-' + state);
|
||||
}
|
||||
}
|
||||
|
||||
_createModal() {
|
||||
const panelBg = this.isDark ? '#1a1a2e' : '#fff';
|
||||
const textColor = this.isDark ? '#e0e0e0' : '#333';
|
||||
const subColor = this.isDark ? '#666' : '#999';
|
||||
const overlayBg = this.isDark ? 'rgba(0,0,0,0.5)' : 'rgba(0,0,0,0.3)';
|
||||
const shadow = this.isDark ? '0 4px 20px rgba(0,0,0,0.4)' : '0 4px 20px rgba(0,0,0,0.15)';
|
||||
|
||||
this.modal = document.createElement('div');
|
||||
this.modal.style.cssText = 'display:none;position:fixed;top:0;left:0;width:100%;height:100%;z-index:10000;';
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;background:' + overlayBg + ';';
|
||||
overlay.addEventListener('click', () => this._closeCaptcha());
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.style.cssText = 'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:' + panelBg + ';border-radius:8px;padding:20px;box-shadow:' + shadow + ';min-width:340px;';
|
||||
|
||||
this.captchaPanel = panel;
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.style.cssText = 'display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;';
|
||||
header.innerHTML = '<span style="font-weight:600;font-size:15px;color:' + textColor + ';">请完成安全验证</span>';
|
||||
|
||||
const refreshBtn = document.createElement('span');
|
||||
refreshBtn.textContent = '↻';
|
||||
refreshBtn.style.cssText = 'cursor:pointer;font-size:18px;color:' + subColor + ';';
|
||||
refreshBtn.addEventListener('click', () => this._loadCaptcha());
|
||||
header.appendChild(refreshBtn);
|
||||
|
||||
const closeBtn = document.createElement('span');
|
||||
closeBtn.textContent = '✕';
|
||||
closeBtn.style.cssText = 'cursor:pointer;font-size:16px;color:' + subColor + ';margin-left:12px;';
|
||||
closeBtn.addEventListener('click', () => this._closeCaptcha());
|
||||
header.appendChild(closeBtn);
|
||||
|
||||
this.captchaContent = document.createElement('div');
|
||||
this.captchaContent.style.cssText = 'position:relative;';
|
||||
|
||||
this.statusBar = document.createElement('div');
|
||||
this.statusBar.style.cssText = 'margin-top:12px;text-align:center;font-size:13px;color:' + subColor + ';';
|
||||
|
||||
panel.appendChild(header);
|
||||
panel.appendChild(this.captchaContent);
|
||||
panel.appendChild(this.statusBar);
|
||||
|
||||
this.modal.appendChild(overlay);
|
||||
this.modal.appendChild(panel);
|
||||
document.body.appendChild(this.modal);
|
||||
}
|
||||
|
||||
async _openCaptcha() {
|
||||
this.modal.style.display = 'block';
|
||||
if (this.options.onOpen) this.options.onOpen();
|
||||
await this._loadCaptcha();
|
||||
}
|
||||
|
||||
_closeCaptcha() {
|
||||
this.modal.style.display = 'none';
|
||||
if (this.options.onClose) this.options.onClose();
|
||||
}
|
||||
|
||||
async _loadCaptcha() {
|
||||
this.statusBar.textContent = '加载中...';
|
||||
this.captchaContent.innerHTML = '';
|
||||
this.verifyToken = null;
|
||||
this._setShieldState('wait');
|
||||
|
||||
try {
|
||||
const url = `${this.options.serverUrl}/challenge/generate`;
|
||||
const params = new URLSearchParams();
|
||||
if (this.options.type) params.set('type', this.options.type);
|
||||
params.set('scene', this.options.scene);
|
||||
|
||||
const resp = await fetch(url + '?' + params.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'X-Site-Key': this.options.siteKey }
|
||||
});
|
||||
const result = await resp.json();
|
||||
|
||||
if (result.code !== 200) {
|
||||
this.statusBar.textContent = result.msg || '加载失败';
|
||||
if (this.options.onFail) this.options.onFail(result);
|
||||
return;
|
||||
}
|
||||
|
||||
this.captchaData = result.data;
|
||||
console.log('[ROTATE-INIT] degree=' + result.data.degree + ' type=' + result.data.type + ' tplW=' + result.data.templateImageWidth + ' tplH=' + result.data.templateImageHeight);
|
||||
this._renderCaptcha();
|
||||
this.statusBar.textContent = '请完成验证';
|
||||
} catch (e) {
|
||||
this.statusBar.textContent = '网络错误';
|
||||
if (this.options.onFail) this.options.onFail({ code: 10000, msg: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
_isClickType(type) {
|
||||
return type === 'WORD_IMAGE_CLICK' || type === 'ICON_CLICK' || type === 'WORD_ORDER_CLICK';
|
||||
}
|
||||
|
||||
_renderCaptcha() {
|
||||
this.captchaContent.innerHTML = '';
|
||||
const data = this.captchaData;
|
||||
if (!data) return;
|
||||
const isRotate = data.type === 'ROTATE';
|
||||
const isConcat = data.type === 'CONCAT';
|
||||
const isCurveDraw = data.type === 'CURVE_DRAW';
|
||||
const isClick = this._isClickType(data.type);
|
||||
|
||||
if (isClick) {
|
||||
this._renderClickCaptcha();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCurveDraw) {
|
||||
this._renderDrawCaptcha();
|
||||
return;
|
||||
}
|
||||
|
||||
const bgImg = document.createElement('img');
|
||||
bgImg.src = data.backgroundImage;
|
||||
bgImg.style.cssText = 'width:100%;display:block;border-radius:4px;';
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.style.cssText = 'position:relative;border-radius:4px;';
|
||||
wrapper.appendChild(bgImg);
|
||||
|
||||
let tplImg = null;
|
||||
let concatTopLayer = null;
|
||||
let concatRandomY = 0;
|
||||
if (isConcat) {
|
||||
concatRandomY = (data.data && data.data.randomY) ? data.data.randomY : Math.round(data.backgroundImageHeight / 2);
|
||||
wrapper.innerHTML = '';
|
||||
wrapper.style.cssText = 'position:relative;border-radius:4px;overflow:hidden;width:100%;height:180px;';
|
||||
const bgLayer = document.createElement('div');
|
||||
bgLayer.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;z-index:1;background-image:url(' + data.backgroundImage + ');background-size:100% 180px;background-position:0 0;background-repeat:no-repeat;';
|
||||
wrapper.appendChild(bgLayer);
|
||||
const overlayHeight = ((data.backgroundImageHeight - concatRandomY) / data.backgroundImageHeight) * 180;
|
||||
concatTopLayer = document.createElement('div');
|
||||
concatTopLayer.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:' + overlayHeight + 'px;z-index:2;overflow:hidden;background-image:url(' + data.backgroundImage + ');background-size:100% 180px;background-position:0 0;background-repeat:no-repeat;';
|
||||
wrapper.appendChild(concatTopLayer);
|
||||
} else if (data.templateImage) {
|
||||
tplImg = document.createElement('img');
|
||||
tplImg.src = data.templateImage;
|
||||
if (isRotate) {
|
||||
const holeLeft = ((data.backgroundImageWidth - data.templateImageWidth) / 2 / data.backgroundImageWidth * 100);
|
||||
const holeTop = ((data.backgroundImageHeight - data.templateImageHeight) / 2 / data.backgroundImageHeight * 100);
|
||||
tplImg.style.cssText = 'position:absolute;left:' + holeLeft + '%;top:' + holeTop + '%;width:' + (data.templateImageWidth / data.backgroundImageWidth * 100) + '%;pointer-events:none;transform:rotate(0deg);transform-origin:center center;z-index:1;';
|
||||
} else {
|
||||
tplImg.style.cssText = 'position:absolute;top:0;left:0;height:100%;pointer-events:none;transition:none;';
|
||||
}
|
||||
wrapper.appendChild(tplImg);
|
||||
}
|
||||
|
||||
this.captchaContent.appendChild(wrapper);
|
||||
|
||||
const sliderBar = document.createElement('div');
|
||||
const barBg = this.isDark ? '#2a2a4a' : '#f0f0f0';
|
||||
sliderBar.style.cssText = 'position:relative;height:40px;width:100%;background:' + barBg + ';border-radius:4px;margin-top:8px;overflow:hidden;';
|
||||
|
||||
const sliderTrack = document.createElement('div');
|
||||
sliderTrack.style.cssText = 'position:absolute;top:0;left:0;height:100%;width:0;background:linear-gradient(90deg,#52c41a,#73d13d);border-radius:4px;transition:none;';
|
||||
|
||||
const sliderBtnBg = this.isDark ? '#1a1a2e' : '#fff';
|
||||
const sliderBtn = document.createElement('div');
|
||||
sliderBtn.style.cssText = 'position:absolute;top:2px;left:0;width:36px;height:36px;background:' + sliderBtnBg + ';border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,0.15);cursor:grab;display:flex;align-items:center;justify-content:center;font-size:16px;color:#999;transition:none;';
|
||||
sliderBtn.textContent = isRotate ? '↻' : '→';
|
||||
|
||||
sliderBar.appendChild(sliderTrack);
|
||||
sliderBar.appendChild(sliderBtn);
|
||||
this.captchaContent.appendChild(sliderBar);
|
||||
|
||||
this.startX = 0;
|
||||
this.currentX = 0;
|
||||
this.trackList = [];
|
||||
this.startTime = Date.now();
|
||||
|
||||
const getBarWidth = () => sliderBar.offsetWidth;
|
||||
|
||||
const onMove = (e) => {
|
||||
if (!this.isDragging) return;
|
||||
const barWidth = getBarWidth();
|
||||
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
||||
const dx = clientX - this.startX;
|
||||
const maxX = barWidth - 36;
|
||||
this.currentX = Math.max(0, Math.min(dx, maxX));
|
||||
|
||||
sliderBtn.style.left = this.currentX + 'px';
|
||||
sliderTrack.style.width = (this.currentX + 18) + 'px';
|
||||
|
||||
const ratio = this.currentX / barWidth;
|
||||
let scaledX;
|
||||
if (isRotate && tplImg) {
|
||||
const bgW = this.captchaData.backgroundImageWidth;
|
||||
const cssDegree = (this.currentX / barWidth) * 360;
|
||||
tplImg.style.transform = 'rotate(' + cssDegree + 'deg)';
|
||||
scaledX = Math.round(ratio * bgW);
|
||||
} else if (isConcat && concatTopLayer) {
|
||||
const moveX = ratio * wrapper.offsetWidth;
|
||||
concatTopLayer.style.backgroundPositionX = moveX + 'px';
|
||||
scaledX = Math.round(ratio * this.captchaData.backgroundImageWidth);
|
||||
} else if (tplImg) {
|
||||
tplImg.style.left = (ratio * 100) + '%';
|
||||
scaledX = Math.round(ratio * this.captchaData.backgroundImageWidth);
|
||||
} else {
|
||||
scaledX = Math.round(ratio * this.captchaData.backgroundImageWidth);
|
||||
}
|
||||
this.trackList.push({
|
||||
x: scaledX,
|
||||
y: 0,
|
||||
t: Date.now() - this.startTime,
|
||||
type: 'MOVE'
|
||||
});
|
||||
};
|
||||
|
||||
const onUp = async () => {
|
||||
if (!this.isDragging) return;
|
||||
this.isDragging = false;
|
||||
sliderBtn.style.cursor = 'grab';
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
document.removeEventListener('touchmove', onMove);
|
||||
document.removeEventListener('touchend', onUp);
|
||||
|
||||
const barWidth = getBarWidth();
|
||||
const ratio = this.currentX / barWidth;
|
||||
const scaledX = Math.round(ratio * this.captchaData.backgroundImageWidth);
|
||||
this.trackList.push({
|
||||
x: scaledX,
|
||||
y: 0,
|
||||
t: Date.now() - this.startTime,
|
||||
type: 'UP'
|
||||
});
|
||||
|
||||
this.statusBar.textContent = '验证中...';
|
||||
this.statusBar.style.color = '#999';
|
||||
|
||||
await this._verify();
|
||||
};
|
||||
|
||||
sliderBtn.addEventListener('mousedown', (e) => {
|
||||
this.isDragging = true;
|
||||
this.startX = e.clientX - this.currentX;
|
||||
this.trackList = [{ x: 0, y: 0, t: 0, type: 'DOWN' }];
|
||||
this.startTime = Date.now();
|
||||
sliderBtn.style.cursor = 'grabbing';
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
});
|
||||
|
||||
sliderBtn.addEventListener('touchstart', (e) => {
|
||||
this.isDragging = true;
|
||||
this.startX = e.touches[0].clientX - this.currentX;
|
||||
this.trackList = [{ x: 0, y: 0, t: 0, type: 'DOWN' }];
|
||||
this.startTime = Date.now();
|
||||
document.addEventListener('touchmove', onMove, { passive: false });
|
||||
document.addEventListener('touchend', onUp);
|
||||
});
|
||||
}
|
||||
|
||||
_renderClickCaptcha() {
|
||||
const data = this.captchaData;
|
||||
const isWordOrder = data.type === 'WORD_ORDER_CLICK';
|
||||
const tipText = isWordOrder ? '请依次点击' : '请依次点击图中文字';
|
||||
|
||||
const tipBar = document.createElement('div');
|
||||
tipBar.style.cssText = 'display:flex;align-items:center;justify-content:space-between;height:40px;margin-bottom:8px;';
|
||||
|
||||
const tipLabel = document.createElement('span');
|
||||
tipLabel.style.cssText = 'font-size:14px;color:' + (this.isDark ? '#e0e0e0' : '#333') + ';';
|
||||
tipLabel.textContent = tipText;
|
||||
|
||||
tipBar.appendChild(tipLabel);
|
||||
|
||||
if (data.templateImage) {
|
||||
const tipImg = document.createElement('img');
|
||||
tipImg.src = data.templateImage;
|
||||
tipImg.style.cssText = 'height:35px;max-width:200px;';
|
||||
tipBar.appendChild(tipImg);
|
||||
}
|
||||
|
||||
this.captchaContent.appendChild(tipBar);
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.style.cssText = 'position:relative;border-radius:4px;overflow:hidden;cursor:crosshair;';
|
||||
|
||||
const bgImg = document.createElement('img');
|
||||
bgImg.src = data.backgroundImage;
|
||||
bgImg.style.cssText = 'width:100%;display:block;border-radius:4px;';
|
||||
wrapper.appendChild(bgImg);
|
||||
|
||||
const clickMask = document.createElement('div');
|
||||
clickMask.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;';
|
||||
wrapper.appendChild(clickMask);
|
||||
|
||||
this.captchaContent.appendChild(wrapper);
|
||||
|
||||
const confirmBtn = document.createElement('div');
|
||||
confirmBtn.style.cssText = 'width:100%;height:35px;border-radius:4px;background:linear-gradient(173deg,#e8a838 0%,#f0c060 100%);font-size:15px;text-align:center;line-height:35px;color:#fff;margin-top:8px;cursor:pointer;';
|
||||
confirmBtn.textContent = '确认';
|
||||
this.captchaContent.appendChild(confirmBtn);
|
||||
|
||||
this.trackList = [];
|
||||
this.startTime = Date.now();
|
||||
this.clickCount = 0;
|
||||
this.clickMarkers = [];
|
||||
|
||||
clickMask.addEventListener('click', (e) => {
|
||||
if (e.target.className === 'tacaptcha-click-marker') return;
|
||||
const rect = bgImg.getBoundingClientRect();
|
||||
const clickX = Math.round(e.clientX - rect.left);
|
||||
const clickY = Math.round(e.clientY - rect.top);
|
||||
const displayW = rect.width;
|
||||
const displayH = rect.height;
|
||||
const scaledX = Math.round((clickX / displayW) * data.backgroundImageWidth);
|
||||
const scaledY = Math.round((clickY / displayH) * data.backgroundImageHeight);
|
||||
|
||||
this.clickCount++;
|
||||
if (this.clickCount === 1) {
|
||||
this.startTime = Date.now();
|
||||
}
|
||||
|
||||
this.trackList.push({
|
||||
x: scaledX,
|
||||
y: scaledY,
|
||||
t: Date.now() - this.startTime,
|
||||
type: 'CLICK'
|
||||
});
|
||||
|
||||
const marker = document.createElement('span');
|
||||
marker.className = 'tacaptcha-click-marker';
|
||||
marker.textContent = this.clickCount;
|
||||
const left = clickX - 11;
|
||||
const top = clickY - 11;
|
||||
marker.style.cssText = 'position:absolute;left:' + left + 'px;top:' + top + 'px;border-radius:50%;background:#409eff;width:22px;height:22px;text-align:center;line-height:22px;color:#fff;border:2px solid #fff;box-sizing:content-box;font-size:12px;pointer-events:none;z-index:2;';
|
||||
clickMask.appendChild(marker);
|
||||
this.clickMarkers.push(marker);
|
||||
});
|
||||
|
||||
confirmBtn.addEventListener('click', async () => {
|
||||
if (this.clickCount === 0) return;
|
||||
this.statusBar.textContent = '验证中...';
|
||||
this.statusBar.style.color = '#999';
|
||||
await this._verify();
|
||||
});
|
||||
}
|
||||
|
||||
_renderDrawCaptcha() {
|
||||
const data = this.captchaData;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.style.cssText = 'position:relative;border-radius:4px;overflow:hidden;';
|
||||
|
||||
const bgImg = document.createElement('img');
|
||||
bgImg.src = data.backgroundImage;
|
||||
bgImg.style.cssText = 'width:100%;display:block;border-radius:4px;';
|
||||
wrapper.appendChild(bgImg);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;touch-action:none;cursor:crosshair;';
|
||||
wrapper.appendChild(canvas);
|
||||
|
||||
const tip = document.createElement('div');
|
||||
tip.textContent = '请在图上绘制曲线';
|
||||
tip.style.cssText = 'position:absolute;top:8px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.6);color:#fff;padding:4px 12px;border-radius:4px;font-size:13px;pointer-events:none;z-index:2;';
|
||||
wrapper.appendChild(tip);
|
||||
|
||||
this.captchaContent.appendChild(wrapper);
|
||||
|
||||
const confirmBtn = document.createElement('div');
|
||||
confirmBtn.style.cssText = 'width:100%;height:35px;border-radius:4px;background:linear-gradient(173deg,#e8a838 0%,#f0c060 100%);font-size:15px;text-align:center;line-height:35px;color:#fff;margin-top:8px;cursor:pointer;';
|
||||
confirmBtn.textContent = '确认';
|
||||
this.captchaContent.appendChild(confirmBtn);
|
||||
|
||||
this.trackList = [];
|
||||
this.startTime = Date.now();
|
||||
this.isDrawing = false;
|
||||
|
||||
let ctx = null;
|
||||
|
||||
const getPoint = (e) => {
|
||||
const rect = bgImg.getBoundingClientRect();
|
||||
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
|
||||
const x = clientX - rect.left;
|
||||
const y = clientY - rect.top;
|
||||
const scaledX = Math.round((x / rect.width) * data.backgroundImageWidth);
|
||||
const scaledY = Math.round((y / rect.height) * data.backgroundImageHeight);
|
||||
return { x, y, scaledX, scaledY };
|
||||
};
|
||||
|
||||
const startDraw = (e) => {
|
||||
e.preventDefault();
|
||||
this.isDrawing = true;
|
||||
const p = getPoint(e);
|
||||
this.trackList.push({ x: p.scaledX, y: p.scaledY, t: 0, type: 'DOWN' });
|
||||
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
canvas.width = rect.width;
|
||||
canvas.height = rect.height;
|
||||
ctx = canvas.getContext('2d');
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p.x, p.y);
|
||||
ctx.lineWidth = 3;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.strokeStyle = 'rgba(64, 158, 255, 0.9)';
|
||||
tip.style.display = 'none';
|
||||
};
|
||||
|
||||
const moveDraw = (e) => {
|
||||
if (!this.isDrawing) return;
|
||||
e.preventDefault();
|
||||
const p = getPoint(e);
|
||||
this.trackList.push({ x: p.scaledX, y: p.scaledY, t: Date.now() - this.startTime, type: 'MOVE' });
|
||||
if (ctx) {
|
||||
ctx.lineTo(p.x, p.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
};
|
||||
|
||||
const endDraw = (e) => {
|
||||
if (!this.isDrawing) return;
|
||||
this.isDrawing = false;
|
||||
let clientX, clientY;
|
||||
if (e.changedTouches && e.changedTouches.length > 0) {
|
||||
clientX = e.changedTouches[0].clientX;
|
||||
clientY = e.changedTouches[0].clientY;
|
||||
} else {
|
||||
clientX = e.clientX;
|
||||
clientY = e.clientY;
|
||||
}
|
||||
const rect = bgImg.getBoundingClientRect();
|
||||
const x = clientX - rect.left;
|
||||
const y = clientY - rect.top;
|
||||
const scaledX = Math.round((x / rect.width) * data.backgroundImageWidth);
|
||||
const scaledY = Math.round((y / rect.height) * data.backgroundImageHeight);
|
||||
this.trackList.push({ x: scaledX, y: scaledY, t: Date.now() - this.startTime, type: 'UP' });
|
||||
};
|
||||
|
||||
canvas.addEventListener('mousedown', startDraw);
|
||||
canvas.addEventListener('mousemove', moveDraw);
|
||||
canvas.addEventListener('mouseup', endDraw);
|
||||
canvas.addEventListener('touchstart', startDraw, { passive: false });
|
||||
canvas.addEventListener('touchmove', moveDraw, { passive: false });
|
||||
canvas.addEventListener('touchend', endDraw);
|
||||
|
||||
confirmBtn.addEventListener('click', async () => {
|
||||
if (this.trackList.length === 0) return;
|
||||
this.statusBar.textContent = '验证中...';
|
||||
this.statusBar.style.color = '#999';
|
||||
await this._verify();
|
||||
});
|
||||
}
|
||||
|
||||
async _verify() {
|
||||
if (!this.captchaData) return;
|
||||
this.statusBar.textContent = '验证中...';
|
||||
|
||||
try {
|
||||
const trackData = {
|
||||
bgImageWidth: this.captchaData.backgroundImageWidth,
|
||||
bgImageHeight: this.captchaData.backgroundImageHeight,
|
||||
templateImageWidth: this.captchaData.templateImageWidth,
|
||||
templateImageHeight: this.captchaData.templateImageHeight,
|
||||
startTime: this.startTime,
|
||||
stopTime: Date.now(),
|
||||
trackList: this.trackList
|
||||
};
|
||||
|
||||
const resp = await fetch(`${this.options.serverUrl}/challenge/verify`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Site-Key': this.options.siteKey
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: this.captchaData.id,
|
||||
data: trackData
|
||||
})
|
||||
});
|
||||
const result = await resp.json();
|
||||
|
||||
if (result.code === 200 && result.data && result.data.verifyToken) {
|
||||
this.verifyToken = result.data.verifyToken;
|
||||
this.statusBar.textContent = '验证成功';
|
||||
this.statusBar.style.color = '#52c41a';
|
||||
if (this.options.onSuccess) {
|
||||
this.options.onSuccess({
|
||||
code: 200,
|
||||
msg: '操作成功',
|
||||
data: { verifyToken: this.verifyToken }
|
||||
});
|
||||
}
|
||||
setTimeout(() => {
|
||||
this._closeCaptcha();
|
||||
this._setShieldState('success');
|
||||
}, 400);
|
||||
} else {
|
||||
this._setShieldState('fail');
|
||||
this.statusBar.textContent = '验证失败,请重试';
|
||||
this.statusBar.style.color = '#ff4d4f';
|
||||
if (this.options.onFail) this.options.onFail(result);
|
||||
setTimeout(() => this._loadCaptcha(), 1500);
|
||||
}
|
||||
} catch (e) {
|
||||
this.statusBar.textContent = '网络错误';
|
||||
if (this.options.onFail) this.options.onFail({ code: 10000, msg: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
async show() {
|
||||
await this._openCaptcha();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.modal && this.modal.parentNode) {
|
||||
this.modal.parentNode.removeChild(this.modal);
|
||||
}
|
||||
if (this.options.elId) {
|
||||
INSTANCES.delete(this.options.elId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const TACaptcha = {
|
||||
async init(options) {
|
||||
const elId = options.elId || ('_tac_' + Date.now());
|
||||
if (INSTANCES.has(elId)) {
|
||||
INSTANCES.get(elId).destroy();
|
||||
}
|
||||
const instance = new TACaptchaInstance(options);
|
||||
INSTANCES.set(elId, instance);
|
||||
return instance;
|
||||
},
|
||||
|
||||
refresh(elId) {
|
||||
const inst = INSTANCES.get(elId);
|
||||
if (inst) inst._loadCaptcha();
|
||||
},
|
||||
|
||||
show(elId) {
|
||||
const inst = INSTANCES.get(elId);
|
||||
if (inst) return inst.show();
|
||||
return Promise.reject(new Error('Instance not found'));
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = TACaptcha;
|
||||
} else {
|
||||
global.TACaptcha = TACaptcha;
|
||||
}
|
||||
})(typeof window !== 'undefined' ? window : this);
|
||||
Reference in New Issue
Block a user