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:
abcv7
2026-08-26 09:58:15 +08:00
parent d56958727c
commit dbe9b58b0c
115 changed files with 10365 additions and 36 deletions
@@ -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);