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,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>tianai-captcha 管理平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,196 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>史诗质感 - 真·双剑与盾行为验证 (强效魔法脉冲版)</title>
|
||||
<style>
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
background-color: #0f172a;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.captcha-container {
|
||||
position: relative;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.captcha-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* --- 1. 盾牌过渡 --- */
|
||||
.shield-shard {
|
||||
transform-origin: 50px 50px;
|
||||
transition: transform 0.7s cubic-bezier(0.22, 1, 0.36, 1), fill 0.4s ease, opacity 0.5s ease;
|
||||
}
|
||||
|
||||
/* --- 2. 强效魔法结界脉冲 --- */
|
||||
.ward-group {
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
.ward-pulse {
|
||||
transform-origin: 50px 50px;
|
||||
/* 加快了一点点脉冲节奏,显得能量更活跃 */
|
||||
animation: pulse-ward 2s cubic-bezier(0.1, 0.7, 0.3, 1) infinite;
|
||||
}
|
||||
.ward-pulse.delay {
|
||||
/* 延迟时间调整,形成错落有致的双重波纹 */
|
||||
animation-delay: 1s;
|
||||
}
|
||||
|
||||
/* 核心修改:大幅增加粗细、透明度和放大倍率 */
|
||||
@keyframes pulse-ward {
|
||||
0% { transform: scale(0.4); opacity: 1; stroke-width: 16px; }
|
||||
50% { opacity: 0.8; }
|
||||
100% { transform: scale(1.7); opacity: 0; stroke-width: 2px; }
|
||||
}
|
||||
|
||||
/* --- 3. 巨剑插入动画 --- */
|
||||
.sword-slide {
|
||||
transform: translateY(-85px);
|
||||
opacity: 0;
|
||||
transition: transform 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.4), opacity 0.4s ease;
|
||||
}
|
||||
|
||||
.sword-anchor-a { transform-origin: 50px 50px; transform: rotate(-45deg); }
|
||||
.sword-anchor-b { transform-origin: 50px 50px; transform: rotate(45deg); }
|
||||
|
||||
|
||||
/* --- 4. 状态控制 --- */
|
||||
.state-wait .ward-group { opacity: 1; }
|
||||
.state-wait .shield-shard {
|
||||
fill: url(#metalBlueGrad);
|
||||
transform: translate(0, 0) scale(1) rotate(0deg);
|
||||
opacity: 1;
|
||||
/* 盾牌本身也加上强力的魔法蓝光阴影 */
|
||||
filter: drop-shadow(0 0 20px rgba(56, 189, 248, 0.6));
|
||||
}
|
||||
|
||||
.state-success .ward-group { opacity: 0; }
|
||||
.state-success .shield-shard {
|
||||
fill: url(#metalGreenGrad);
|
||||
transform: translate(0, 0) scale(1) rotate(0deg);
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 6px 8px rgba(0,0,0,0.5));
|
||||
}
|
||||
.state-success .sword-slide { transform: translateY(-15px); opacity: 1; }
|
||||
|
||||
.state-fail .ward-group { opacity: 0; }
|
||||
.state-fail .shield-shard { fill: url(#metalRedGrad); opacity: 0.9; filter: none;}
|
||||
.state-fail .shard-tl { transform: translate(-25px, -30px) scale(0.9) rotateX(30deg) rotateY(-20deg) rotateZ(-15deg); opacity: 0.7; }
|
||||
.state-fail .shard-tr { transform: translate(25px, -30px) scale(0.9) rotateX(30deg) rotateY(20deg) rotateZ(15deg); opacity: 0.7; }
|
||||
.state-fail .shard-b { transform: translate(0, 35px) scale(0.8) rotateX(-20deg) rotateZ(5deg); opacity: 0.5; }
|
||||
|
||||
|
||||
.controls { display: flex; gap: 15px; }
|
||||
.controls button {
|
||||
padding: 12px 24px; font-size: 14px; font-weight: bold; cursor: pointer;
|
||||
border: 2px solid transparent; border-radius: 8px; background: #1e293b; color: #f1f5f9;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.controls button:hover { background: #334155; transform: translateY(-2px); }
|
||||
.controls button:active { transform: translateY(1px); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="captcha-container">
|
||||
<svg id="captcha-svg" class="captcha-icon state-wait" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="metalBlueGrad" 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="metalGreenGrad" 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="metalRedGrad" 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="bladeLight" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#f8fafc" /><stop offset="100%" stop-color="#94a3b8" />
|
||||
</linearGradient>
|
||||
<linearGradient id="bladeDark" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#64748b" /><stop offset="100%" stop-color="#334155" />
|
||||
</linearGradient>
|
||||
<linearGradient id="goldGrad" 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="swordShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="3" dy="5" stdDeviation="2.5" flood-color="#000000" flood-opacity="0.7"/>
|
||||
</filter>
|
||||
|
||||
<filter id="epicMagicGlow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur in="SourceGraphic" stdDeviation="2" result="blur1" />
|
||||
<feGaussianBlur in="SourceGraphic" stdDeviation="6" result="blur2" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur2" />
|
||||
<feMergeNode in="blur1" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
<g id="epic-sword" filter="url(#swordShadow)">
|
||||
<polygon points="43,26 50,26 50,115" fill="url(#bladeLight)" />
|
||||
<polygon points="50,26 57,26 50,115" fill="url(#bladeDark)" />
|
||||
<rect x="45" y="4" width="10" height="22" fill="#1e293b" />
|
||||
<path d="M 45 6 L 55 10 M 45 12 L 55 16 M 45 18 L 55 22 M 45 24 L 55 28" stroke="#475569" stroke-width="2" />
|
||||
<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(#goldGrad)" />
|
||||
<polygon points="50,-6 57,0 50,6 43,0" fill="url(#goldGrad)" />
|
||||
<polygon points="50,-6 57,0 50,6" fill="rgba(0,0,0,0.25)" />
|
||||
</g>
|
||||
</defs>
|
||||
|
||||
<g class="ward-group" filter="url(#epicMagicGlow)">
|
||||
<circle class="ward-pulse" cx="50" cy="50" r="30" fill="rgba(56, 189, 248, 0.15)" stroke="#38BDF8" stroke-linecap="round" />
|
||||
<circle class="ward-pulse delay" cx="50" cy="50" r="30" fill="rgba(56, 189, 248, 0.15)" 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(#metalBlueGrad)" />
|
||||
<path class="shield-shard shard-tr" d="M 50 12 L 82 28 L 82 52 L 50 48 Z" fill="url(#metalBlueGrad)" />
|
||||
<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(#metalBlueGrad)" />
|
||||
</g>
|
||||
|
||||
<g class="sword-anchor-a">
|
||||
<g class="sword-slide">
|
||||
<use href="#epic-sword" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g class="sword-anchor-b">
|
||||
<g class="sword-slide">
|
||||
<use href="#epic-sword" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button onclick="changeState('state-wait')" style="border-color: #3B82F6;">🛡️ 结界维持中...</button>
|
||||
<button onclick="changeState('state-success')" style="border-color: #10B981; color: #10B981;">⚔️ 史诗验证成功</button>
|
||||
<button onclick="changeState('state-fail')" style="border-color: #EF4444; color: #EF4444;">💥 盾牌碎裂</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function changeState(newStateClass) {
|
||||
const svg = document.getElementById('captcha-svg');
|
||||
svg.classList.remove('state-wait', 'state-success', 'state-fail');
|
||||
svg.classList.add(newStateClass);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 |
@@ -0,0 +1,191 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>史诗质感 - 真·双剑与盾行为验证 (锐利光环版)</title>
|
||||
<style>
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
background-color: #0f172a;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.captcha-container {
|
||||
position: relative;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.captcha-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* --- 1. 盾牌过渡 --- */
|
||||
.shield-shard {
|
||||
transform-origin: 50px 50px;
|
||||
transition: transform 0.7s cubic-bezier(0.22, 1, 0.36, 1), fill 0.4s ease, opacity 0.5s ease;
|
||||
}
|
||||
|
||||
/* --- 2. 纯净空心脉冲 --- */
|
||||
.ward-group {
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
.ward-pulse {
|
||||
transform-origin: 50px 50px;
|
||||
animation: pulse-ward 2s cubic-bezier(0.1, 0.7, 0.3, 1) infinite;
|
||||
}
|
||||
.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; }
|
||||
}
|
||||
|
||||
/* --- 3. 巨剑插入动画 --- */
|
||||
.sword-slide {
|
||||
transform: translateY(-85px);
|
||||
opacity: 0;
|
||||
transition: transform 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.4), opacity 0.4s ease;
|
||||
}
|
||||
|
||||
.sword-anchor-a { transform-origin: 50px 50px; transform: rotate(-45deg); }
|
||||
.sword-anchor-b { transform-origin: 50px 50px; transform: rotate(45deg); }
|
||||
|
||||
|
||||
/* --- 4. 状态控制 --- */
|
||||
.state-wait .ward-group { opacity: 1; }
|
||||
.state-wait .shield-shard {
|
||||
fill: url(#metalBlueGrad);
|
||||
transform: translate(0, 0) scale(1) rotate(0deg);
|
||||
opacity: 1;
|
||||
/* 【核心修复】:移除发光,改回黑色的物理实体阴影,保证盾牌边缘极其锐利 */
|
||||
filter: drop-shadow(0 6px 8px rgba(0,0,0,0.6));
|
||||
}
|
||||
|
||||
.state-success .ward-group { opacity: 0; }
|
||||
.state-success .shield-shard {
|
||||
fill: url(#metalGreenGrad);
|
||||
transform: translate(0, 0) scale(1) rotate(0deg);
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 6px 8px rgba(0,0,0,0.6));
|
||||
}
|
||||
.state-success .sword-slide { transform: translateY(-15px); opacity: 1; }
|
||||
|
||||
.state-fail .ward-group { opacity: 0; }
|
||||
.state-fail .shield-shard { fill: url(#metalRedGrad); opacity: 0.9; filter: none;}
|
||||
.state-fail .shard-tl { transform: translate(-25px, -30px) scale(0.9) rotateX(30deg) rotateY(-20deg) rotateZ(-15deg); opacity: 0.7; }
|
||||
.state-fail .shard-tr { transform: translate(25px, -30px) scale(0.9) rotateX(30deg) rotateY(20deg) rotateZ(15deg); opacity: 0.7; }
|
||||
.state-fail .shard-b { transform: translate(0, 35px) scale(0.8) rotateX(-20deg) rotateZ(5deg); opacity: 0.5; }
|
||||
|
||||
|
||||
.controls { display: flex; gap: 15px; }
|
||||
.controls button {
|
||||
padding: 12px 24px; font-size: 14px; font-weight: bold; cursor: pointer;
|
||||
border: 2px solid transparent; border-radius: 8px; background: #1e293b; color: #f1f5f9;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.controls button:hover { background: #334155; transform: translateY(-2px); }
|
||||
.controls button:active { transform: translateY(1px); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="captcha-container">
|
||||
<svg id="captcha-svg" class="captcha-icon state-wait" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="metalBlueGrad" 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="metalGreenGrad" 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="metalRedGrad" 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="bladeLight" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#f8fafc" /><stop offset="100%" stop-color="#94a3b8" />
|
||||
</linearGradient>
|
||||
<linearGradient id="bladeDark" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#64748b" /><stop offset="100%" stop-color="#334155" />
|
||||
</linearGradient>
|
||||
<linearGradient id="goldGrad" 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="swordShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="3" dy="5" stdDeviation="2.5" flood-color="#000000" flood-opacity="0.7"/>
|
||||
</filter>
|
||||
|
||||
<filter id="cleanMagicGlow" 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="epic-sword" filter="url(#swordShadow)">
|
||||
<polygon points="43,26 50,26 50,115" fill="url(#bladeLight)" />
|
||||
<polygon points="50,26 57,26 50,115" fill="url(#bladeDark)" />
|
||||
<rect x="45" y="4" width="10" height="22" fill="#1e293b" />
|
||||
<path d="M 45 6 L 55 10 M 45 12 L 55 16 M 45 18 L 55 22 M 45 24 L 55 28" stroke="#475569" stroke-width="2" />
|
||||
<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(#goldGrad)" />
|
||||
<polygon points="50,-6 57,0 50,6 43,0" fill="url(#goldGrad)" />
|
||||
<polygon points="50,-6 57,0 50,6" fill="rgba(0,0,0,0.25)" />
|
||||
</g>
|
||||
</defs>
|
||||
|
||||
<g class="ward-group" filter="url(#cleanMagicGlow)">
|
||||
<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(#metalBlueGrad)" />
|
||||
<path class="shield-shard shard-tr" d="M 50 12 L 82 28 L 82 52 L 50 48 Z" fill="url(#metalBlueGrad)" />
|
||||
<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(#metalBlueGrad)" />
|
||||
</g>
|
||||
|
||||
<g class="sword-anchor-a">
|
||||
<g class="sword-slide">
|
||||
<use href="#epic-sword" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g class="sword-anchor-b">
|
||||
<g class="sword-slide">
|
||||
<use href="#epic-sword" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button onclick="changeState('state-wait')" style="border-color: #3B82F6;">🛡️ 结界维持中...</button>
|
||||
<button onclick="changeState('state-success')" style="border-color: #10B981; color: #10B981;">⚔️ 史诗验证成功</button>
|
||||
<button onclick="changeState('state-fail')" style="border-color: #EF4444; color: #EF4444;">💥 盾牌碎裂</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function changeState(newStateClass) {
|
||||
const svg = document.getElementById('captcha-svg');
|
||||
svg.classList.remove('state-wait', 'state-success', 'state-fail');
|
||||
svg.classList.add(newStateClass);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+1966
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "tianai-captcha-platform-ui",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.0",
|
||||
"naive-ui": "^2.40.0",
|
||||
"axios": "^1.7.0",
|
||||
"vue-router": "^4.4.0",
|
||||
"pinia": "^2.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<n-config-provider>
|
||||
<!-- 测试页:全屏,不带侧边栏 -->
|
||||
<div v-if="route.path === '/test'" style="height:100vh;">
|
||||
<router-view />
|
||||
</div>
|
||||
<!-- 其他页面:带侧边栏 -->
|
||||
<n-layout v-else has-sider style="height:100vh">
|
||||
<n-layout-sider bordered :width="220">
|
||||
<div style="padding:16px 20px;font-size:18px;font-weight:700;color:#1890ff;">tianai-captcha</div>
|
||||
<n-menu :options="menuOptions" :value="currentRoute" @update:value="onMenuSelect" />
|
||||
</n-layout-sider>
|
||||
<n-layout>
|
||||
<n-layout-header bordered style="padding:12px 24px;font-size:16px;font-weight:500;">
|
||||
验证码管理平台
|
||||
</n-layout-header>
|
||||
<n-layout-content style="padding:24px;">
|
||||
<router-view />
|
||||
</n-layout-content>
|
||||
</n-layout>
|
||||
</n-layout>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { NConfigProvider, NLayout, NLayoutSider, NLayoutHeader, NLayoutContent, NMenu } from 'naive-ui'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const currentRoute = computed(() => route.path)
|
||||
|
||||
const menuOptions = [
|
||||
{ label: '仪表盘', key: '/' },
|
||||
{ label: '站点管理', key: '/sites' },
|
||||
{ label: '统计监控', key: '/stats' },
|
||||
{ label: '验证测试', key: '/test' },
|
||||
]
|
||||
|
||||
function onMenuSelect(key) {
|
||||
router.push(key)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import App from './App.vue'
|
||||
import Dashboard from './views/Dashboard.vue'
|
||||
import Sites from './views/Sites.vue'
|
||||
import Stats from './views/Stats.vue'
|
||||
import CaptchaTest from './views/CaptchaTest.vue'
|
||||
|
||||
const routes = [
|
||||
{ path: '/', component: Dashboard },
|
||||
{ path: '/sites', component: Sites },
|
||||
{ path: '/stats', component: Stats },
|
||||
{ path: '/test', component: CaptchaTest },
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
createApp(App).use(router).mount('#app')
|
||||
@@ -0,0 +1,240 @@
|
||||
<template>
|
||||
<div style="display:flex;gap:0;height:100vh;overflow:hidden;">
|
||||
<!-- 左侧:请求配置 -->
|
||||
<div style="width:360px;flex-shrink:0;overflow-y:auto;border-right:1px solid #f0f0f0;padding:20px;background:#fafafa;">
|
||||
<div style="font-size:16px;font-weight:600;margin-bottom:20px;color:#333;">请求配置</div>
|
||||
<n-form label-placement="top" size="small">
|
||||
<n-form-item label="SiteKey">
|
||||
<n-select v-model:value="cfg.siteKey" :options="siteOptions" placeholder="选择站点" filterable />
|
||||
</n-form-item>
|
||||
<n-form-item label="验证码类型">
|
||||
<n-select v-model:value="cfg.type" :options="typeOptions" placeholder="自动选择" clearable />
|
||||
</n-form-item>
|
||||
<n-form-item label="场景">
|
||||
<n-input v-model:value="cfg.scene" placeholder="default" />
|
||||
</n-form-item>
|
||||
<n-form-item label="服务地址">
|
||||
<n-input v-model:value="cfg.serverUrl" />
|
||||
</n-form-item>
|
||||
<n-form-item label="样式模式">
|
||||
<n-radio-group v-model:value="cfg.styleMode" size="small">
|
||||
<n-radio-button value="light">浅色</n-radio-button>
|
||||
<n-radio-button value="dark">深色</n-radio-button>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-button type="primary" block @click="initCaptcha" :disabled="!cfg.siteKey" style="margin-top:8px;">
|
||||
初始化验证码
|
||||
</n-button>
|
||||
|
||||
<!-- 请求日志 -->
|
||||
<div style="margin-top:20px;">
|
||||
<div style="font-size:13px;font-weight:600;margin-bottom:8px;color:#666;">请求日志</div>
|
||||
<div ref="logContainer" style="height:200px;overflow-y:auto;font-family:monospace;font-size:11px;background:#fff;border:1px solid #e8e8e8;border-radius:6px;padding:8px;">
|
||||
<div v-for="(log, i) in logs" :key="i" :style="{color: logColor(log.type), marginBottom:'2px', lineHeight:'18px'}">
|
||||
<span style="color:#bbb;">[{{ log.time }}]</span> {{ log.msg }}
|
||||
</div>
|
||||
<div v-if="logs.length === 0" style="color:#ccc;">等待操作...</div>
|
||||
</div>
|
||||
<n-button size="tiny" @click="logs = []" style="margin-top:4px;">清空</n-button>
|
||||
</div>
|
||||
|
||||
<!-- 验证结果 -->
|
||||
<div style="margin-top:16px;">
|
||||
<div style="font-size:13px;font-weight:600;margin-bottom:8px;color:#666;">验证结果</div>
|
||||
<div v-if="!verifyResult" style="color:#ccc;font-size:12px;">暂无结果</div>
|
||||
<div v-else>
|
||||
<n-tag :type="verifyResult.success ? 'success' : 'error'" size="small" style="margin-bottom:6px;">
|
||||
{{ verifyResult.success ? '验证成功' : '验证失败' }}
|
||||
</n-tag>
|
||||
<div v-if="verifyResult.token" style="word-break:break-all;font-size:11px;color:#888;background:#f6ffed;border:1px solid #b7eb8f;border-radius:4px;padding:6px;">
|
||||
<div style="font-weight:600;color:#52c41a;margin-bottom:2px;">verifyToken</div>
|
||||
{{ verifyResult.token }}
|
||||
</div>
|
||||
<div v-if="verifyResult.error" style="font-size:11px;color:#ff4d4f;">{{ verifyResult.error }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:验证码展示区 -->
|
||||
<div style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;"
|
||||
:style="rightBgStyle">
|
||||
|
||||
<!-- Logo + 标题 -->
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:24px;">
|
||||
<img src="/logo/logo.svg" style="height:36px;" />
|
||||
<span :style="{color: cfg.styleMode === 'dark' ? '#aaa' : '#999', fontSize:'13px'}">
|
||||
{{ cfg.styleMode === 'dark' ? '深色模式 - 行为验证码' : '浅色模式 - 行为验证码' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 验证码触发区 -->
|
||||
<div id="captcha-trigger-zone" style="width:340px;"></div>
|
||||
|
||||
<!-- 验证码弹窗容器 -->
|
||||
<div id="captcha-modal-zone"></div>
|
||||
|
||||
<!-- 状态提示 -->
|
||||
<div v-if="statusMsg" style="margin-top:16px;width:340px;">
|
||||
<div :style="statusBarStyle">{{ statusMsg }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, nextTick, computed, watch } from 'vue'
|
||||
import { NSelect, NInput, NButton, NForm, NFormItem, NTag, NRadioGroup, NRadioButton } from 'naive-ui'
|
||||
import axios from 'axios'
|
||||
|
||||
const cfg = reactive({
|
||||
siteKey: '',
|
||||
type: null,
|
||||
scene: 'default',
|
||||
serverUrl: 'http://localhost:18200/api',
|
||||
styleMode: 'light',
|
||||
})
|
||||
|
||||
const siteOptions = ref([])
|
||||
const typeOptions = [
|
||||
{ label: 'SLIDER 滑块', value: 'SLIDER' },
|
||||
{ label: 'SLIDER_V2 增强滑块', value: 'SLIDER_V2' },
|
||||
{ label: 'ROTATE 旋转', value: 'ROTATE' },
|
||||
{ label: 'CONCAT 滑动还原', value: 'CONCAT' },
|
||||
{ label: 'WORD_IMAGE_CLICK 文字点选', value: 'WORD_IMAGE_CLICK' },
|
||||
{ label: 'ICON_CLICK 图标点选', value: 'ICON_CLICK' },
|
||||
{ label: 'WORD_ORDER_CLICK 语序点选', value: 'WORD_ORDER_CLICK' },
|
||||
{ label: 'CURVE_SLIDER 曲线滑块', value: 'CURVE_SLIDER' },
|
||||
{ label: 'ANGLE 角度', value: 'ANGLE' },
|
||||
{ label: 'SCRATCH 刮刮乐', value: 'SCRATCH' },
|
||||
{ label: 'JIGSAW 乱序拼图', value: 'JIGSAW' },
|
||||
{ label: 'CURVE_DRAW 曲线绘制', value: 'CURVE_DRAW' },
|
||||
]
|
||||
|
||||
const logs = ref([])
|
||||
const verifyResult = ref(null)
|
||||
const statusMsg = ref('')
|
||||
const logContainer = ref(null)
|
||||
const captchaInstance = ref(null)
|
||||
|
||||
const rightBgStyle = ref({})
|
||||
const statusBarStyle = ref({})
|
||||
|
||||
watch(() => cfg.styleMode, (v) => {
|
||||
if (v === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark')
|
||||
rightBgStyle.value = { background: 'linear-gradient(135deg, #0f0c29, #302b63, #24243e)' }
|
||||
statusBarStyle.value = { color: '#888', fontSize: '12px', textAlign: 'center' }
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
rightBgStyle.value = { background: '#f5f5f5' }
|
||||
statusBarStyle.value = { color: '#999', fontSize: '12px', textAlign: 'center' }
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
function log(type, msg) {
|
||||
const time = new Date().toLocaleTimeString('zh-CN')
|
||||
logs.value.push({ type, msg, time })
|
||||
if (logs.value.length > 80) logs.value.shift()
|
||||
nextTick(() => {
|
||||
if (logContainer.value) logContainer.value.scrollTop = logContainer.value.scrollHeight
|
||||
})
|
||||
}
|
||||
|
||||
function logColor(type) {
|
||||
if (type === 'req') return '#1890ff'
|
||||
if (type === 'res') return '#52c41a'
|
||||
if (type === 'err') return '#ff4d4f'
|
||||
return '#999'
|
||||
}
|
||||
|
||||
async function loadSites() {
|
||||
try {
|
||||
const resp = await axios.get('/api/admin/sites')
|
||||
if (resp.data?.code === 200) {
|
||||
const content = resp.data.data?.content || []
|
||||
siteOptions.value = content.map(s => ({
|
||||
label: s.name + ' (' + s.siteKey + ')',
|
||||
value: s.siteKey,
|
||||
}))
|
||||
if (content.length === 1) cfg.siteKey = content[0].siteKey
|
||||
}
|
||||
} catch (e) {
|
||||
log('err', '加载站点失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function initCaptcha() {
|
||||
if (!cfg.siteKey) return
|
||||
verifyResult.value = null
|
||||
statusMsg.value = '加载中...'
|
||||
log('info', '初始化验证码...')
|
||||
|
||||
if (captchaInstance.value) {
|
||||
captchaInstance.value.destroy()
|
||||
captchaInstance.value = null
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
const triggerEl = document.getElementById('captcha-trigger-zone')
|
||||
if (triggerEl) triggerEl.innerHTML = ''
|
||||
|
||||
const modalEl = document.getElementById('captcha-modal-zone')
|
||||
if (modalEl) modalEl.innerHTML = ''
|
||||
|
||||
const isDark = cfg.styleMode === 'dark'
|
||||
|
||||
try {
|
||||
if (!window.TACaptcha) {
|
||||
const script = document.createElement('script')
|
||||
script.src = '/sdk/captcha.js'
|
||||
document.head.appendChild(script)
|
||||
await new Promise((resolve, reject) => {
|
||||
script.onload = resolve
|
||||
script.onerror = () => reject(new Error('加载 captcha.js 失败'))
|
||||
})
|
||||
}
|
||||
|
||||
const trigger = document.createElement('div')
|
||||
trigger.id = 'test-captcha-btn'
|
||||
trigger.style.cssText = 'width:100%;'
|
||||
|
||||
triggerEl.appendChild(trigger)
|
||||
|
||||
log('req', 'POST ' + cfg.serverUrl + '/challenge/generate?type=' + (cfg.type || 'auto'))
|
||||
log('info', 'X-Site-Key: ' + cfg.siteKey)
|
||||
|
||||
captchaInstance.value = window.TACaptcha.init({
|
||||
elId: 'test-captcha-btn',
|
||||
siteKey: cfg.siteKey,
|
||||
type: cfg.type,
|
||||
scene: cfg.scene,
|
||||
serverUrl: cfg.serverUrl,
|
||||
logoUrl: '/logo/logo.svg',
|
||||
theme: isDark ? 'dark' : 'light',
|
||||
mode: 'click',
|
||||
onSuccess: (result) => {
|
||||
log('res', '验证成功! token: ' + result.data.verifyToken.substring(0, 40) + '...')
|
||||
verifyResult.value = { success: true, token: result.data.verifyToken }
|
||||
statusMsg.value = '验证成功'
|
||||
},
|
||||
onFail: (err) => {
|
||||
log('err', '验证失败: ' + JSON.stringify(err))
|
||||
verifyResult.value = { success: false, error: err?.msg || '基础校验失败' }
|
||||
statusMsg.value = '验证失败'
|
||||
},
|
||||
onOpen: () => log('info', '弹窗打开'),
|
||||
onClose: () => log('info', '弹窗关闭'),
|
||||
})
|
||||
|
||||
log('res', '初始化完成')
|
||||
statusMsg.value = '请点击上方按钮进行验证'
|
||||
} catch (e) {
|
||||
log('err', '初始化失败: ' + e.message)
|
||||
statusMsg.value = '初始化失败: ' + e.message
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadSites)
|
||||
</script>
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<n-space vertical :size="24">
|
||||
<n-h2>仪表盘</n-h2>
|
||||
<n-grid :cols="4" :x-gap="16" :y-gap="16">
|
||||
<n-gi><n-statistic label="请求总数" :value="stats.total" /></n-gi>
|
||||
<n-gi><n-statistic label="通过总数" :value="stats.success" /></n-gi>
|
||||
<n-gi><n-statistic label="失败总数" :value="stats.fail" /></n-gi>
|
||||
<n-gi><n-statistic label="总通过率" :value="stats.passRate" /></n-gi>
|
||||
</n-grid>
|
||||
<n-h3>验证码类型</n-h3>
|
||||
<n-grid :cols="4" :x-gap="12" :y-gap="12">
|
||||
<n-gi v-for="t in types" :key="t.key">
|
||||
<n-card size="small" :title="t.label">
|
||||
<template #header-extra><n-tag size="small">{{ t.key }}</n-tag></template>
|
||||
已注册
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { NSpace, NH2, NH3, NGrid, NGi, NStatistic, NCard, NTag } from 'naive-ui'
|
||||
import axios from 'axios'
|
||||
|
||||
const stats = ref({ total: 0, success: 0, fail: 0, passRate: '0%' })
|
||||
const types = [
|
||||
{ key: 'SLIDER', label: '滑块验证' },
|
||||
{ key: 'SLIDER_V2', label: '增强滑块' },
|
||||
{ key: 'ROTATE', label: '旋转验证' },
|
||||
{ key: 'CONCAT', label: '滑动还原' },
|
||||
{ key: 'WORD_IMAGE_CLICK', label: '文字点选' },
|
||||
{ key: 'ICON_CLICK', label: '图标点选' },
|
||||
{ key: 'WORD_ORDER_CLICK', label: '语序点选' },
|
||||
{ key: 'CURVE_SLIDER', label: '曲线滑块' },
|
||||
{ key: 'ANGLE', label: '角度验证' },
|
||||
{ key: 'SCRATCH', label: '刮刮乐' },
|
||||
{ key: 'JIGSAW', label: '乱序拼图' },
|
||||
{ key: 'CURVE_DRAW', label: '曲线绘制' },
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const resp = await axios.get('/api/admin/stats?days=7')
|
||||
if (resp.data?.code === 200) stats.value = resp.data.data
|
||||
} catch {}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<n-space vertical :size="20">
|
||||
<n-h2>站点管理
|
||||
<template #suffix>
|
||||
<n-button type="primary" @click="showCreate = true">创建站点</n-button>
|
||||
</template>
|
||||
</n-h2>
|
||||
<n-data-table :columns="columns" :data="sites" :pagination="{ pageSize: 20 }" :bordered="false" />
|
||||
|
||||
<n-modal v-model:show="showCreate" preset="dialog" title="创建站点" positive-text="创建" @positive-click="createSite">
|
||||
<n-form>
|
||||
<n-form-item label="站点名称"><n-input v-model:value="form.name" /></n-form-item>
|
||||
<n-form-item label="域名"><n-input v-model:value="form.domain" placeholder="lovehome.safina520.cn" /></n-form-item>
|
||||
<n-form-item label="验证码类型">
|
||||
<n-checkbox-group v-model:value="form.captchaTypes">
|
||||
<n-space>
|
||||
<n-checkbox v-for="t in allTypes" :key="t.value" :value="t.value" :label="t.label" />
|
||||
</n-space>
|
||||
</n-checkbox-group>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-modal>
|
||||
|
||||
<n-modal v-model:show="showDetail" preset="dialog" title="站点详情" :show-action="false" style="width:600px">
|
||||
<n-descriptions v-if="detailSite" bordered :column="1" label-placement="left" size="small">
|
||||
<n-descriptions-item label="ID">{{ detailSite.id }}</n-descriptions-item>
|
||||
<n-descriptions-item label="名称">{{ detailSite.name }}</n-descriptions-item>
|
||||
<n-descriptions-item label="域名">{{ detailSite.domain }}</n-descriptions-item>
|
||||
<n-descriptions-item label="SiteKey">
|
||||
<n-tag size="small" :bordered="false">{{ detailSite.siteKey }}</n-tag>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="SecretKey">
|
||||
<n-tag size="small" :bordered="false">{{ detailSite.secretKey }}</n-tag>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="验证等级">{{ detailSite.verifyLevel }}</n-descriptions-item>
|
||||
<n-descriptions-item label="QPS">{{ detailSite.qps }}</n-descriptions-item>
|
||||
<n-descriptions-item label="日限制">{{ detailSite.dailyLimit }}</n-descriptions-item>
|
||||
<n-descriptions-item label="验证码类型">
|
||||
<n-space>
|
||||
<n-tag v-for="t in (detailSite.captchaTypes || [])" :key="t" size="small">{{ t }}</n-tag>
|
||||
</n-space>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="状态">
|
||||
<n-tag :type="detailSite.isEnabled ? 'success' : 'error'" size="small">{{ detailSite.isEnabled ? '启用' : '禁用' }}</n-tag>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="轨迹验证">{{ detailSite.trackValidationEnabled ? '开启' : '关闭' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="对抗扰动">{{ detailSite.obfuscationEnabled ? '开启' : '关闭' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="端到端加密">{{ detailSite.encryptionEnabled ? '开启' : '关闭' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="创建时间">{{ formatTime(detailSite.createdAt) }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-modal>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, h } from 'vue'
|
||||
import { NSpace, NH2, NButton, NDataTable, NModal, NForm, NFormItem, NInput, NTag, NCheckboxGroup, NCheckbox, NDescriptions, NDescriptionsItem } from 'naive-ui'
|
||||
import axios from 'axios'
|
||||
|
||||
const sites = ref([])
|
||||
const showCreate = ref(false)
|
||||
const showDetail = ref(false)
|
||||
const detailSite = ref(null)
|
||||
const form = ref({ name: '', domain: '', captchaTypes: ['SLIDER', 'ROTATE', 'ICON_CLICK'] })
|
||||
|
||||
const allTypes = [
|
||||
{ value: 'SLIDER', label: '滑块验证' },
|
||||
{ value: 'SLIDER_V2', label: '增强滑块' },
|
||||
{ value: 'ROTATE', label: '旋转验证' },
|
||||
{ value: 'CONCAT', label: '滑动还原' },
|
||||
{ value: 'WORD_IMAGE_CLICK', label: '文字点选' },
|
||||
{ value: 'ICON_CLICK', label: '图标点选' },
|
||||
{ value: 'WORD_ORDER_CLICK', label: '语序点选' },
|
||||
{ value: 'CURVE_SLIDER', label: '曲线滑块' },
|
||||
{ value: 'ANGLE', label: '角度验证' },
|
||||
{ value: 'SCRATCH', label: '刮刮乐' },
|
||||
{ value: 'JIGSAW', label: '乱序拼图' },
|
||||
{ value: 'CURVE_DRAW', label: '曲线绘制' },
|
||||
]
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', key: 'id', width: 60 },
|
||||
{ title: '名称', key: 'name', width: 120 },
|
||||
{ title: '域名', key: 'domain', width: 160 },
|
||||
{
|
||||
title: 'SiteKey', key: 'siteKey', width: 200,
|
||||
render: (row) => h(NTag, { size: 'small', bordered: false }, () => row.siteKey)
|
||||
},
|
||||
{
|
||||
title: '验证等级', key: 'verifyLevel', width: 90,
|
||||
render: (row) => h(NTag, {
|
||||
type: row.verifyLevel === 'HIGH' ? 'error' : row.verifyLevel === 'MEDIUM' ? 'warning' : 'info', size: 'small'
|
||||
}, () => row.verifyLevel)
|
||||
},
|
||||
{
|
||||
title: '状态', key: 'isEnabled', width: 70,
|
||||
render: (row) => h(NTag, { type: row.isEnabled ? 'success' : 'error', size: 'small' }, () => row.isEnabled ? '启用' : '禁用')
|
||||
},
|
||||
{
|
||||
title: '创建时间', key: 'createdAt', width: 160,
|
||||
render: (row) => formatTime(row.createdAt)
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'actions', width: 200,
|
||||
render: (row) => h('div', { style: 'display:flex;gap:8px;' }, [
|
||||
h(NButton, { size: 'small', onClick: () => viewSite(row) }, () => '详情'),
|
||||
h(NButton, { size: 'small', onClick: () => toggleSite(row) }, () => row.isEnabled ? '禁用' : '启用'),
|
||||
h(NButton, { size: 'small', type: 'error', onClick: () => deleteSite(row.id) }, () => '删除'),
|
||||
])
|
||||
},
|
||||
]
|
||||
|
||||
function formatTime(v) {
|
||||
if (!v) return '-'
|
||||
return new Date(v).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
async function loadSites() {
|
||||
try {
|
||||
const resp = await axios.get('/api/admin/sites')
|
||||
if (resp.data?.code === 200) sites.value = resp.data.data?.content || []
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function createSite() {
|
||||
await axios.post('/api/admin/sites', {
|
||||
name: form.value.name,
|
||||
domain: form.value.domain,
|
||||
captchaTypes: form.value.captchaTypes,
|
||||
})
|
||||
form.value = { name: '', domain: '', captchaTypes: ['SLIDER', 'ROTATE', 'ICON_CLICK'] }
|
||||
await loadSites()
|
||||
}
|
||||
|
||||
function viewSite(row) {
|
||||
detailSite.value = row
|
||||
showDetail.value = true
|
||||
}
|
||||
|
||||
async function toggleSite(row) {
|
||||
row.isEnabled = !row.isEnabled
|
||||
await axios.put(`/api/admin/sites/${row.id}`, row)
|
||||
await loadSites()
|
||||
}
|
||||
|
||||
async function deleteSite(id) {
|
||||
await axios.delete(`/api/admin/sites/${id}`)
|
||||
await loadSites()
|
||||
}
|
||||
|
||||
onMounted(loadSites)
|
||||
</script>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<n-space vertical :size="20">
|
||||
<n-h2>统计监控</n-h2>
|
||||
<n-grid :cols="4" :x-gap="16" :y-gap="16">
|
||||
<n-gi><n-statistic label="请求总数" :value="stats.total" /></n-gi>
|
||||
<n-gi><n-statistic label="通过总数" :value="stats.success" /></n-gi>
|
||||
<n-gi><n-statistic label="失败总数" :value="stats.fail" /></n-gi>
|
||||
<n-gi><n-statistic label="总通过率" :value="stats.passRate" /></n-gi>
|
||||
</n-grid>
|
||||
<n-h3>IP黑名单管理</n-h3>
|
||||
<n-space>
|
||||
<n-input v-model:value="banForm.ip" placeholder="IP地址" style="width:200px" />
|
||||
<n-input v-model:value="banForm.reason" placeholder="封禁原因" style="width:200px" />
|
||||
<n-button type="error" @click="banIp">封禁IP</n-button>
|
||||
</n-space>
|
||||
<n-data-table :columns="ipColumns" :data="bannedIps" :bordered="false" />
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, h } from 'vue'
|
||||
import { NSpace, NH2, NH3, NGrid, NGi, NStatistic, NDataTable, NButton, NInput, NTag } from 'naive-ui'
|
||||
import axios from 'axios'
|
||||
|
||||
const stats = ref({ total: 0, success: 0, fail: 0, passRate: '0%' })
|
||||
const bannedIps = ref([])
|
||||
const banForm = ref({ ip: '', reason: '' })
|
||||
|
||||
const ipColumns = [
|
||||
{ title: 'IP', key: 'ip' },
|
||||
{ title: '原因', key: 'reason' },
|
||||
{ title: '解封时间', key: 'banUntil', render: (row) => row.banUntil ? new Date(row.banUntil).toLocaleString('zh-CN') : '永久' },
|
||||
{
|
||||
title: '操作', key: 'actions',
|
||||
render: (row) => h(NButton, { size: 'small', type: 'warning', onClick: () => unbanIp(row.ip) }, () => '解封')
|
||||
},
|
||||
]
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const resp = await axios.get('/api/admin/stats?days=7')
|
||||
if (resp.data?.code === 200) stats.value = resp.data.data
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function banIp() {
|
||||
if (!banForm.value.ip) return
|
||||
await axios.post('/api/admin/ip-blacklist', {
|
||||
ip: banForm.value.ip,
|
||||
reason: banForm.value.reason,
|
||||
durationMs: 3600000,
|
||||
})
|
||||
banForm.value = { ip: '', reason: '' }
|
||||
await loadBannedIps()
|
||||
}
|
||||
|
||||
async function unbanIp(ip) {
|
||||
await axios.delete(`/api/admin/ip-blacklist/${ip}`)
|
||||
await loadBannedIps()
|
||||
}
|
||||
|
||||
async function loadBannedIps() {
|
||||
bannedIps.value = []
|
||||
}
|
||||
|
||||
onMounted(() => { loadStats(); loadBannedIps() })
|
||||
</script>
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:18200',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user