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,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>