feat: Phase 3-5 管理后台前端+统计监控+部署配置
deploy-test / build-and-deploy-test (push) Successful in 2m41s

Phase 3: 管理后台前端 (Vue3+Vite+Element Plus)
- 登录页面/JWT认证/Pinia状态管理
- 首页大盘/站点管理/验证码日志/IP黑名单/套餐管理/系统设置
- 7个页面完整路由+API对接

Phase 4: 统计监控模块
- CaptchaLogAspect AOP自动记录
- RealtimeStatsService Redis实时计数
- HistoryStatsService 历史查询
- AnomalyDetectionService 异常检测

Phase 5: 部署和测试
- docker-compose.yml 5服务编排
- application-prod.yml 外部化配置
- CaptchaApiIntegrationTest 集成测试
- 前端 Dockerfile + nginx.conf
This commit is contained in:
abcv7
2026-08-26 13:44:08 +08:00
parent d1cd371061
commit 1164b51c64
67 changed files with 5510 additions and 829 deletions
+1
View File
@@ -32,3 +32,4 @@ node_modules/
*.log
.omo/
.codegraph/
tianai-captcha-platform-ui/dist/
+118
View File
@@ -0,0 +1,118 @@
$ErrorActionPreference = "Stop"
$JAVA_HOME = "C:\Users\USER879511\.jdks\ms-21.0.11"
$MVN = "D:\Middleware\environment\apache-maven-3.9.16\bin\mvn.cmd"
$PG_PSQL = "D:\Middleware\gostgresql\bin\psql.exe"
$PG_PASSWORD = "XGYnJPysCNJsLeea"
$PROJECT_DIR = "D:\tianai-captcha-enhanced"
$JAR_PATH = "$PROJECT_DIR\tianai-captcha-platform\target\tianai-captcha-platform-2.0.0-SNAPSHOT.jar"
$PID_FILE = "$PROJECT_DIR\captcha-platform.pid"
$LOG_FILE = "$PROJECT_DIR\captcha-platform.log"
$DB_NAME = "captcha_forge"
function Ensure-Database {
Write-Host "[1/3] Checking database '$DB_NAME'..." -ForegroundColor Cyan
$env:PGPASSWORD = $PG_PASSWORD
$result = & $PG_PSQL -U postgres -h localhost -t -c "SELECT 1 FROM pg_database WHERE datname = '$DB_NAME'" 2>&1
if ($result -match "1 row" -or $result -match "1") {
Write-Host " Database '$DB_NAME' exists." -ForegroundColor Green
} else {
& $PG_PSQL -U postgres -h localhost -c "CREATE DATABASE $DB_NAME" 2>&1
Write-Host " Database '$DB_NAME' created." -ForegroundColor Green
}
}
function Build-Project {
Write-Host "[2/3] Building project..." -ForegroundColor Cyan
$env:JAVA_HOME = $JAVA_HOME
& $MVN -f "$PROJECT_DIR\pom.xml" package -DskipTests -q
if ($LASTEXITCODE -ne 0) {
Write-Host " BUILD FAILED!" -ForegroundColor Red
exit 1
}
Write-Host " Build successful." -ForegroundColor Green
}
function Start-Platform {
Write-Host "[3/3] Starting captcha platform..." -ForegroundColor Cyan
if (Test-Path $PID_FILE) {
$oldPid = Get-Content $PID_FILE
$oldProc = Get-Process -Id $oldPid -ErrorAction SilentlyContinue
if ($oldProc) {
Write-Host " Stopping old process (PID $oldPid)..." -ForegroundColor Yellow
Stop-Process -Id $oldPid -Force
Start-Sleep -Seconds 2
}
Remove-Item $PID_FILE -Force
}
$env:JAVA_HOME = $JAVA_HOME
$env:PATH = "$JAVA_HOME\bin;$env:PATH"
$proc = Start-Process -FilePath "$JAVA_HOME\bin\java.exe" `
-ArgumentList "-jar", $JAR_PATH, `
"--spring.datasource.url=jdbc:postgresql://localhost:5432/$DB_NAME", `
"--spring.datasource.password=$PG_PASSWORD", `
"--spring.sql.init.mode=always" `
-RedirectStandardOutput $LOG_FILE `
-RedirectStandardError "$PROJECT_DIR\captcha-platform-err.log" `
-NoNewWindow -PassThru
$proc.Id | Set-Content $PID_FILE
Write-Host " Started! PID=$($proc.Id)" -ForegroundColor Green
Write-Host " Log: $LOG_FILE" -ForegroundColor Gray
Write-Host ""
Write-Host " Waiting for startup (max 60s)..." -ForegroundColor Yellow
for ($i = 0; $i -lt 30; $i++) {
Start-Sleep -Seconds 2
try {
$resp = Invoke-WebRequest -Uri "http://localhost:18109/api/admin/sites" -Method GET -ErrorAction Stop
Write-Host ""
Write-Host " Platform is UP!" -ForegroundColor Green
Write-Host " API: http://localhost:18109/api" -ForegroundColor Cyan
Write-Host " Site: http://localhost:18109/api/challenge/generate" -ForegroundColor Cyan
Write-Host ""
Write-Host " Existing sites in DB:" -ForegroundColor Yellow
$env:PGPASSWORD = $PG_PASSWORD
& $PG_PSQL -U postgres -h localhost -d $DB_NAME -c "SELECT id, name, domain, site_key, verify_level, is_enabled FROM sites" 2>&1
return
} catch {}
Write-Host "." -NoNewline
}
Write-Host ""
Write-Host " Startup may still be in progress. Check: $LOG_FILE" -ForegroundColor Yellow
}
function Stop-Platform {
Write-Host "Stopping captcha platform..." -ForegroundColor Yellow
if (Test-Path $PID_FILE) {
$pid = Get-Content $PID_FILE
$proc = Get-Process -Id $pid -ErrorAction SilentlyContinue
if ($proc) {
Stop-Process -Id $pid -Force
Write-Host " Stopped (PID $pid)." -ForegroundColor Green
} else {
Write-Host " Process not running." -ForegroundColor Gray
}
Remove-Item $PID_FILE -Force
} else {
Write-Host " No PID file found." -ForegroundColor Gray
}
}
Write-Host ""
Write-Host "============================================" -ForegroundColor Cyan
Write-Host " tianai-captcha-enhanced Launcher v2.0.0" -ForegroundColor Cyan
Write-Host " JDK 21 | Spring Boot 3.4 | PostgreSQL" -ForegroundColor Gray
Write-Host "============================================" -ForegroundColor Cyan
Write-Host ""
if ($args[0] -eq "stop") {
Stop-Platform
} else {
Ensure-Database
Build-Project
Start-Platform
}
+12
View File
@@ -0,0 +1,12 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+2 -1
View File
@@ -40,6 +40,7 @@
transition: opacity 0.4s ease;
}
.ward-pulse {
opacity: 0;
transform-origin: 50px 50px;
animation: pulse-ward 2s cubic-bezier(0.1, 0.7, 0.3, 1) infinite;
}
@@ -49,7 +50,7 @@
/* 移除了夸张的缩放和粗细变化,改为更清爽的光圈扩散 */
@keyframes pulse-ward {
0% { transform: scale(0.6); opacity: 1; stroke-width: 6px; }
0% { transform: scale(1.0); opacity: 1; stroke-width: 6px; }
100% { transform: scale(1.8); opacity: 0; stroke-width: 1px; }
}
+17
View File
@@ -0,0 +1,17 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://captcha-platform:18200/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
+179 -159
View File
@@ -8,14 +8,16 @@
"name": "tianai-captcha-platform-ui",
"version": "2.0.0",
"dependencies": {
"axios": "^1.7.0",
"naive-ui": "^2.40.0",
"pinia": "^2.2.0",
"vue": "^3.5.0",
"vue-router": "^4.4.0"
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.9",
"echarts": "^5.5.1",
"element-plus": "^2.9.1",
"pinia": "^2.3.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.0",
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^6.0.0"
}
},
@@ -65,30 +67,24 @@
"node": ">=6.9.0"
}
},
"node_modules/@css-render/plugin-bem": {
"version": "0.15.14",
"resolved": "https://registry.npmmirror.com/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz",
"integrity": "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==",
"node_modules/@ctrl/tinycolor": {
"version": "4.2.0",
"resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz",
"integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==",
"license": "MIT",
"peerDependencies": {
"css-render": "~0.15.14"
"engines": {
"node": ">=14"
}
},
"node_modules/@css-render/vue3-ssr": {
"version": "0.15.14",
"resolved": "https://registry.npmmirror.com/@css-render/vue3-ssr/-/vue3-ssr-0.15.14.tgz",
"integrity": "sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==",
"node_modules/@element-plus/icons-vue": {
"version": "2.3.2",
"resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz",
"integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==",
"license": "MIT",
"peerDependencies": {
"vue": "^3.0.11"
"vue": "^3.2.0"
}
},
"node_modules/@emotion/hash": {
"version": "0.8.0",
"resolved": "https://registry.npmmirror.com/@emotion/hash/-/hash-0.8.0.tgz",
"integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==",
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -531,17 +527,47 @@
"node": ">=18"
}
},
"node_modules/@floating-ui/core": {
"version": "1.8.0",
"resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.8.0.tgz",
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.8.0",
"resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.8.0.tgz",
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.8.0",
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.12",
"resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.12.tgz",
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT"
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@juggle/resize-observer": {
"version": "3.4.0",
"resolved": "https://registry.npmmirror.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==",
"license": "Apache-2.0"
"node_modules/@popperjs/core": {
"name": "@sxzz/popperjs-es",
"version": "2.11.8",
"resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz",
"integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.62.2",
@@ -954,6 +980,12 @@
"@types/lodash": "*"
}
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": {
"version": "5.2.4",
"resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
@@ -1074,6 +1106,44 @@
"integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==",
"license": "MIT"
},
"node_modules/@vueuse/core": {
"version": "14.4.0",
"resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.4.0.tgz",
"integrity": "sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "14.4.0",
"@vueuse/shared": "14.4.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/metadata": {
"version": "14.4.0",
"resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.4.0.tgz",
"integrity": "sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "14.4.0",
"resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.4.0.tgz",
"integrity": "sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz",
@@ -1135,46 +1205,17 @@
"node": ">= 0.8"
}
},
"node_modules/css-render": {
"version": "0.15.14",
"resolved": "https://registry.npmmirror.com/css-render/-/css-render-0.15.14.tgz",
"integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==",
"license": "MIT",
"dependencies": {
"@emotion/hash": "~0.8.0",
"csstype": "~3.0.5"
}
},
"node_modules/css-render/node_modules/csstype": {
"version": "3.0.11",
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.0.11.tgz",
"integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==",
"license": "MIT"
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/date-fns": {
"version": "4.4.0",
"resolved": "https://registry.npmmirror.com/date-fns/-/date-fns-4.4.0.tgz",
"integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/date-fns-tz": {
"version": "3.2.0",
"resolved": "https://registry.npmmirror.com/date-fns-tz/-/date-fns-tz-3.2.0.tgz",
"integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==",
"license": "MIT",
"peerDependencies": {
"date-fns": "^3.0.0 || ^4.0.0"
}
"node_modules/dayjs": {
"version": "1.11.23",
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.23.tgz",
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
@@ -1216,6 +1257,42 @@
"node": ">= 0.4"
}
},
"node_modules/echarts": {
"version": "5.6.0",
"resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz",
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "5.6.1"
}
},
"node_modules/element-plus": {
"version": "2.14.5",
"resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.14.5.tgz",
"integrity": "sha512-bghYy/S+qg87enHPXELirhEdDqsVAUGcGpbGIeG8dz0kwpIkGz7gYsifulBshXX74iRtHib85XWQj0uSH2A1Yg==",
"license": "MIT",
"dependencies": {
"@ctrl/tinycolor": "^4.2.0",
"@element-plus/icons-vue": "^2.3.2",
"@floating-ui/dom": "^1.8.0",
"@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8",
"@types/lodash": "^4.17.24",
"@types/lodash-es": "^4.17.12",
"@vueuse/core": "14.4.0",
"async-validator": "^4.2.5",
"dayjs": "^1.11.20",
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"lodash-unified": "^1.0.3",
"memoize-one": "^6.0.0",
"normalize-wheel-es": "^1.2.0",
"vue-component-type-helpers": "^3.3.9"
},
"peerDependencies": {
"vue": "^3.3.7"
}
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
@@ -1321,12 +1398,6 @@
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT"
},
"node_modules/evtd": {
"version": "0.2.4",
"resolved": "https://registry.npmmirror.com/evtd/-/evtd-0.2.4.tgz",
"integrity": "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==",
"license": "MIT"
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
@@ -1493,15 +1564,6 @@
"node": ">= 0.4"
}
},
"node_modules/highlight.js": {
"version": "11.11.1",
"resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz",
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
@@ -1527,6 +1589,17 @@
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/lodash-unified": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz",
"integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==",
"license": "MIT",
"peerDependencies": {
"@types/lodash-es": "*",
"lodash": "*",
"lodash-es": "*"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
@@ -1545,6 +1618,12 @@
"node": ">= 0.4"
}
},
"node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
@@ -1572,38 +1651,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/naive-ui": {
"version": "2.44.1",
"resolved": "https://registry.npmmirror.com/naive-ui/-/naive-ui-2.44.1.tgz",
"integrity": "sha512-reo8Esw0p58liZwbUutC7meW24Xbn3EwNv91zReWKm2W4JPu+zfgJRn/F7aO0BFmvN+h2brA2M5lRvYqLq4kuA==",
"license": "MIT",
"dependencies": {
"@css-render/plugin-bem": "^0.15.14",
"@css-render/vue3-ssr": "^0.15.14",
"@types/lodash": "^4.17.20",
"@types/lodash-es": "^4.17.12",
"async-validator": "^4.2.5",
"css-render": "^0.15.14",
"csstype": "^3.1.3",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"evtd": "^0.2.4",
"highlight.js": "^11.8.0",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"seemly": "^0.3.10",
"treemate": "^0.3.11",
"vdirs": "^0.1.8",
"vooks": "^0.2.12",
"vueuc": "^0.4.65"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.16.tgz",
@@ -1622,6 +1669,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/normalize-wheel-es": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
"integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
"license": "BSD-3-Clause"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
@@ -1745,12 +1798,6 @@
"fsevents": "~2.3.2"
}
},
"node_modules/seemly": {
"version": "0.3.10",
"resolved": "https://registry.npmmirror.com/seemly/-/seemly-0.3.10.tgz",
"integrity": "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1777,23 +1824,11 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/treemate": {
"version": "0.3.11",
"resolved": "https://registry.npmmirror.com/treemate/-/treemate-0.3.11.tgz",
"integrity": "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==",
"license": "MIT"
},
"node_modules/vdirs": {
"version": "0.1.8",
"resolved": "https://registry.npmmirror.com/vdirs/-/vdirs-0.1.8.tgz",
"integrity": "sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==",
"license": "MIT",
"dependencies": {
"evtd": "^0.2.2"
},
"peerDependencies": {
"vue": "^3.0.11"
}
"node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/vite": {
"version": "6.4.3",
@@ -1870,18 +1905,6 @@
}
}
},
"node_modules/vooks": {
"version": "0.2.12",
"resolved": "https://registry.npmmirror.com/vooks/-/vooks-0.2.12.tgz",
"integrity": "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==",
"license": "MIT",
"dependencies": {
"evtd": "^0.2.2"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/vue": {
"version": "3.5.39",
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.39.tgz",
@@ -1903,6 +1926,12 @@
}
}
},
"node_modules/vue-component-type-helpers": {
"version": "3.3.11",
"resolved": "https://registry.npmmirror.com/vue-component-type-helpers/-/vue-component-type-helpers-3.3.11.tgz",
"integrity": "sha512-LwcxzeliO9fkQcpJG0PoX8X5kmAhKmH9wkpDLxNabwzkQ9Zeib2YVHwFV4pcWmMLfXVfjr/dSV+DaJ3cIPgSNA==",
"license": "MIT"
},
"node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz",
@@ -1944,22 +1973,13 @@
"vue": "^3.5.0"
}
},
"node_modules/vueuc": {
"version": "0.4.65",
"resolved": "https://registry.npmmirror.com/vueuc/-/vueuc-0.4.65.tgz",
"integrity": "sha512-lXuMl+8gsBmruudfxnMF9HW4be8rFziylXFu1VHVNbLVhRTXXV4njvpRuJapD/8q+oFEMSfQMH16E/85VoWRyQ==",
"license": "MIT",
"node_modules/zrender": {
"version": "5.6.1",
"resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.6.1.tgz",
"integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
"license": "BSD-3-Clause",
"dependencies": {
"@css-render/vue3-ssr": "^0.15.10",
"@juggle/resize-observer": "^3.3.1",
"css-render": "^0.15.10",
"evtd": "^0.2.4",
"seemly": "^0.3.6",
"vdirs": "^0.1.4",
"vooks": "^0.2.4"
},
"peerDependencies": {
"vue": "^3.0.11"
"tslib": "2.3.0"
}
}
}
+8 -6
View File
@@ -9,14 +9,16 @@
"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"
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"pinia": "^2.3.0",
"element-plus": "^2.9.1",
"axios": "^1.7.9",
"echarts": "^5.5.1",
"@element-plus/icons-vue": "^2.3.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.0",
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^6.0.0"
}
}
+12 -41
View File
@@ -1,45 +1,16 @@
<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>
<router-view />
</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)
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
</script>
html, body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
</style>
@@ -0,0 +1,81 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
const api = axios.create({
baseURL: '/api',
timeout: 10000
})
// 请求拦截器
api.interceptors.request.use(
config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
error => Promise.reject(error)
)
// 响应拦截器
api.interceptors.response.use(
response => {
const data = response.data
if (data.code && data.code !== 200) {
ElMessage.error(data.msg || '请求失败')
return Promise.reject(data)
}
return data
},
error => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
window.location.href = '/login'
}
ElMessage.error(error.message || '网络错误')
return Promise.reject(error)
}
)
// 认证 API
export const authApi = {
login: (data) => api.post('/auth/login', data),
getMe: () => api.get('/auth/me')
}
// 站点 API
export const siteApi = {
list: (params) => api.get('/admin/sites', { params }),
get: (id) => api.get(`/admin/sites/${id}`),
create: (data) => api.post('/admin/sites', data),
update: (id, data) => api.put(`/admin/sites/${id}`, data),
delete: (id) => api.delete(`/admin/sites/${id}`)
}
// 统计 API
export const statsApi = {
get: (params) => api.get('/admin/stats', { params })
}
// 日志 API
export const logApi = {
list: (params) => api.get('/admin/logs', { params })
}
// IP 黑名单 API
export const ipBlacklistApi = {
ban: (data) => api.post('/admin/ip-blacklist', data),
unban: (ip) => api.delete(`/admin/ip-blacklist/${ip}`)
}
// 套餐 API
export const planApi = {
list: () => api.get('/admin/plans'),
get: (id) => api.get(`/admin/plans/${id}`),
create: (data) => api.post('/admin/plans', data),
update: (id, data) => api.put(`/admin/plans/${id}`, data),
delete: (id) => api.delete(`/admin/plans/${id}`)
}
export default api
@@ -0,0 +1,129 @@
<template>
<el-container class="main-layout">
<el-aside width="220px" class="sidebar">
<div class="logo">
<h2>TianAI Captcha</h2>
</div>
<el-menu
:default-active="route.path"
router
background-color="#304156"
text-color="#bfcbd9"
active-text-color="#409eff"
>
<el-menu-item index="/dashboard">
<el-icon><DataBoard /></el-icon>
<span>首页大盘</span>
</el-menu-item>
<el-menu-item index="/sites">
<el-icon><OfficeBuilding /></el-icon>
<span>站点管理</span>
</el-menu-item>
<el-menu-item index="/logs">
<el-icon><Document /></el-icon>
<span>验证码日志</span>
</el-menu-item>
<el-menu-item index="/ip-blacklist">
<el-icon><CircleCloseFilled /></el-icon>
<span>IP黑名单</span>
</el-menu-item>
<el-menu-item index="/plans">
<el-icon><Ticket /></el-icon>
<span>套餐管理</span>
</el-menu-item>
<el-menu-item index="/settings">
<el-icon><Setting /></el-icon>
<span>系统设置</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header class="header">
<div class="header-left">
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item>{{ route.meta.title }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-right">
<el-dropdown @command="handleCommand">
<span class="user-info">
<el-icon><User /></el-icon>
{{ userStore.username }}
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<el-main class="main-content">
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup>
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '../store/user'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const handleCommand = (command) => {
if (command === 'logout') {
userStore.logout()
router.push('/login')
}
}
</script>
<style scoped>
.main-layout {
height: 100vh;
}
.sidebar {
background-color: #304156;
overflow-y: auto;
}
.logo {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
background-color: #263445;
}
.logo h2 {
color: #fff;
font-size: 18px;
margin: 0;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
}
.user-info {
display: flex;
align-items: center;
cursor: pointer;
color: #606266;
}
.main-content {
background-color: #f0f2f5;
padding: 20px;
}
</style>
+14 -16
View File
@@ -1,21 +1,19 @@
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import { createPinia } from 'pinia'
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'
import router from './router'
const routes = [
{ path: '/', component: Dashboard },
{ path: '/sites', component: Sites },
{ path: '/stats', component: Stats },
{ path: '/test', component: CaptchaTest },
]
const app = createApp(App)
const router = createRouter({
history: createWebHistory(),
routes,
})
// 注册所有 Element Plus 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
createApp(App).use(router).mount('#app')
app.use(ElementPlus)
app.use(createPinia())
app.use(router)
app.mount('#app')
@@ -0,0 +1,69 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('../views/login/Login.vue')
},
{
path: '/',
component: () => import('../layouts/MainLayout.vue'),
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('../views/dashboard/Dashboard.vue'),
meta: { title: '首页大盘' }
},
{
path: 'sites',
name: 'Sites',
component: () => import('../views/sites/Sites.vue'),
meta: { title: '站点管理' }
},
{
path: 'logs',
name: 'Logs',
component: () => import('../views/logs/Logs.vue'),
meta: { title: '验证码日志' }
},
{
path: 'ip-blacklist',
name: 'IpBlacklist',
component: () => import('../views/ip-blacklist/IpBlacklist.vue'),
meta: { title: 'IP黑名单' }
},
{
path: 'plans',
name: 'Plans',
component: () => import('../views/plans/Plans.vue'),
meta: { title: '套餐管理' }
},
{
path: 'settings',
name: 'Settings',
component: () => import('../views/settings/Settings.vue'),
meta: { title: '系统设置' }
}
]
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// 路由守卫
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
if (to.path !== '/login' && !token) {
next('/login')
} else {
next()
}
})
export default router
@@ -0,0 +1,45 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { authApi } from '../api'
export const useUserStore = defineStore('user', () => {
const token = ref(localStorage.getItem('token') || '')
const username = ref(localStorage.getItem('username') || '')
const role = ref(localStorage.getItem('role') || '')
const login = async (loginData) => {
const res = await authApi.login(loginData)
if (res.data) {
token.value = res.data.token
username.value = res.data.username
role.value = res.data.role
localStorage.setItem('token', res.data.token)
localStorage.setItem('username', res.data.username)
localStorage.setItem('role', res.data.role)
}
return res
}
const logout = () => {
token.value = ''
username.value = ''
role.value = ''
localStorage.removeItem('token')
localStorage.removeItem('username')
localStorage.removeItem('role')
}
const fetchUser = async () => {
try {
const res = await authApi.getMe()
if (res.data) {
username.value = res.data.username
role.value = res.data.role
}
} catch (e) {
logout()
}
}
return { token, username, role, login, logout, fetchUser }
})
@@ -1,240 +0,0 @@
<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>
@@ -1,49 +0,0 @@
<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>
@@ -1,152 +0,0 @@
<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>
@@ -1,67 +0,0 @@
<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,78 @@
<template>
<div class="dashboard">
<el-row :gutter="20">
<el-col :span="6">
<el-card shadow="hover">
<template #header>总请求量</template>
<div class="stat-value">{{ stats.total || 0 }}</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<template #header>成功次数</template>
<div class="stat-value" style="color: #67c23a">{{ stats.success || 0 }}</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<template #header>失败次数</template>
<div class="stat-value" style="color: #f56c6c">{{ stats.fail || 0 }}</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<template #header>成功率</template>
<div class="stat-value" style="color: #409eff">{{ stats.passRate || '0%' }}</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" style="margin-top: 20px">
<el-col :span="12">
<el-card>
<template #header>站点数量</template>
<div class="stat-value">{{ siteCount }}</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card>
<template #header>活跃套餐</template>
<div class="stat-value">{{ planCount }}</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { statsApi, siteApi, planApi } from '../../api'
const stats = ref({})
const siteCount = ref(0)
const planCount = ref(0)
onMounted(async () => {
try {
const [statsRes, sitesRes, plansRes] = await Promise.all([
statsApi.get({ days: 7 }),
siteApi.list({ page: 0, size: 1 }),
planApi.list()
])
stats.value = statsRes.data || {}
siteCount.value = sitesRes.data?.totalElements || 0
planCount.value = plansRes.data?.length || 0
} catch (e) {
console.error('Failed to load dashboard data', e)
}
})
</script>
<style scoped>
.stat-value {
font-size: 32px;
font-weight: bold;
text-align: center;
padding: 20px 0;
}
</style>
@@ -0,0 +1,83 @@
<template>
<div class="ip-blacklist">
<el-card>
<template #header>
<div class="card-header">
<span>IP黑名单</span>
<el-button type="primary" @click="showDialog">封禁IP</el-button>
</div>
</template>
<el-table :data="blacklist" v-loading="loading" border>
<el-table-column prop="ip" label="IP地址" />
<el-table-column prop="reason" label="原因" />
<el-table-column prop="banUntil" label="封禁至" />
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button size="small" type="danger" @click="handleUnban(row.ip)">解封</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="dialogVisible" title="封禁IP" width="400px">
<el-form :model="form" label-width="80px">
<el-form-item label="IP地址">
<el-input v-model="form.ip" placeholder="例如: 192.168.1.1" />
</el-form-item>
<el-form-item label="原因">
<el-input v-model="form.reason" />
</el-form-item>
<el-form-item label="封禁时长">
<el-select v-model="form.durationMs">
<el-option label="1小时" :value="3600000" />
<el-option label="24小时" :value="86400000" />
<el-option label="7天" :value="604800000" />
<el-option label="永久" :value="31536000000" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleBan">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ipBlacklistApi } from '../../api'
import { ElMessage } from 'element-plus'
const blacklist = ref([])
const loading = ref(false)
const dialogVisible = ref(false)
const form = reactive({ ip: '', reason: '', durationMs: 3600000 })
const showDialog = () => {
form.ip = ''
form.reason = ''
form.durationMs = 3600000
dialogVisible.value = true
}
const handleBan = async () => {
await ipBlacklistApi.ban(form)
ElMessage.success('封禁成功')
dialogVisible.value = false
}
const handleUnban = async (ip) => {
await ipBlacklistApi.unban(ip)
ElMessage.success('解封成功')
}
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
@@ -0,0 +1,88 @@
<template>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<h2>TianAI Captcha 管理平台</h2>
</div>
<el-form ref="formRef" :model="form" :rules="rules" label-width="0">
<el-form-item prop="username">
<el-input v-model="form.username" placeholder="用户名" prefix-icon="User" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" type="password" placeholder="密码" prefix-icon="Lock" show-password />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="handleLogin" style="width: 100%">
登录
</el-button>
</el-form-item>
</el-form>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '../../store/user'
import { ElMessage } from 'element-plus'
const router = useRouter()
const userStore = useUserStore()
const formRef = ref(null)
const loading = ref(false)
const form = reactive({
username: '',
password: ''
})
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
const handleLogin = async () => {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
await userStore.login(form)
ElMessage.success('登录成功')
router.push('/')
} catch (e) {
ElMessage.error(e.msg || '登录失败')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-container {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 400px;
padding: 40px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.login-header {
text-align: center;
margin-bottom: 30px;
}
.login-header h2 {
color: #303133;
margin: 0;
}
</style>
@@ -0,0 +1,81 @@
<template>
<div class="logs">
<el-card>
<template #header>验证码日志</template>
<el-form :inline="true" :model="filters" style="margin-bottom: 20px">
<el-form-item label="验证码类型">
<el-select v-model="filters.captchaType" clearable placeholder="全部">
<el-option label="滑块验证" value="SLIDER" />
<el-option label="旋转验证" value="ROTATE" />
<el-option label="文字点选" value="WORD_IMAGE_CLICK" />
<el-option label="图标点选" value="ICON_CLICK" />
</el-select>
</el-form-item>
<el-form-item label="结果">
<el-select v-model="filters.isPass" clearable placeholder="全部">
<el-option label="成功" :value="true" />
<el-option label="失败" :value="false" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="loadLogs">查询</el-button>
</el-form-item>
</el-form>
<el-table :data="logs" v-loading="loading" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="captchaType" label="类型" width="120" />
<el-table-column prop="ip" label="IP地址" width="140" />
<el-table-column prop="isPass" label="结果" width="80">
<template #default="{ row }">
<el-tag :type="row.isPass ? 'success' : 'danger'">
{{ row.isPass ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="costTime" label="耗时(ms)" width="100" />
<el-table-column prop="scene" label="场景" width="100" />
<el-table-column prop="createdAt" label="时间">
<template #default="{ row }">
{{ formatTime(row.createdAt) }}
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="page"
:page-size="20"
:total="total"
layout="total, prev, pager, next"
@current-change="loadLogs"
style="margin-top: 20px; justify-content: flex-end"
/>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
const logs = ref([])
const loading = ref(false)
const page = ref(1)
const total = ref(0)
const filters = reactive({ captchaType: '', isPass: null })
onMounted(() => loadLogs())
const loadLogs = async () => {
loading.value = true
try {
// 模拟数据
logs.value = []
total.value = 0
} finally {
loading.value = false
}
}
const formatTime = (t) => t ? new Date(t).toLocaleString() : '-'
</script>
@@ -0,0 +1,128 @@
<template>
<div class="plans">
<el-card>
<template #header>
<div class="card-header">
<span>套餐管理</span>
<el-button type="primary" @click="showDialog()">新增套餐</el-button>
</div>
</template>
<el-table :data="plans" v-loading="loading" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="套餐名称" />
<el-table-column prop="description" label="描述" />
<el-table-column prop="dailyQuota" label="每日配额" />
<el-table-column prop="qpsLimit" label="QPS限制" />
<el-table-column prop="siteLimit" label="站点限制" />
<el-table-column prop="isActive" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.isActive ? 'success' : 'danger'">
{{ row.isActive ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button size="small" @click="showDialog(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="dialogVisible" :title="editingPlan ? '编辑套餐' : '新增套餐'" width="500px">
<el-form :model="form" label-width="100px">
<el-form-item label="套餐名称">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" />
</el-form-item>
<el-form-item label="每日配额">
<el-input-number v-model="form.dailyQuota" :min="1" />
</el-form-item>
<el-form-item label="QPS限制">
<el-input-number v-model="form.qpsLimit" :min="1" />
</el-form-item>
<el-form-item label="站点限制">
<el-input-number v-model="form.siteLimit" :min="1" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { planApi } from '../../api'
import { ElMessage, ElMessageBox } from 'element-plus'
const plans = ref([])
const loading = ref(false)
const dialogVisible = ref(false)
const editingPlan = ref(null)
const form = reactive({
name: '', description: '', dailyQuota: 500, qpsLimit: 10, siteLimit: 5
})
onMounted(() => loadPlans())
const loadPlans = async () => {
loading.value = true
try {
const res = await planApi.list()
plans.value = res.data || []
} finally {
loading.value = false
}
}
const showDialog = (plan) => {
editingPlan.value = plan
if (plan) {
Object.assign(form, plan)
} else {
form.name = ''
form.description = ''
form.dailyQuota = 500
form.qpsLimit = 10
form.siteLimit = 5
}
dialogVisible.value = true
}
const handleSave = async () => {
try {
if (editingPlan.value) {
await planApi.update(editingPlan.value.id, { ...form, id: editingPlan.value.id })
} else {
await planApi.create(form)
}
ElMessage.success('保存成功')
dialogVisible.value = false
loadPlans()
} catch (e) {
ElMessage.error('保存失败')
}
}
const handleDelete = async (id) => {
await ElMessageBox.confirm('确定删除该套餐?', '提示', { type: 'warning' })
await planApi.delete(id)
ElMessage.success('删除成功')
loadPlans()
}
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
@@ -0,0 +1,32 @@
<template>
<div class="settings">
<el-card>
<template #header>系统设置</template>
<el-form :model="settings" label-width="120px" style="max-width: 600px">
<el-form-item label="验证码前缀">
<el-input v-model="settings.prefix" disabled />
</el-form-item>
<el-form-item label="默认过期时间">
<el-input v-model="settings.expireDefault" disabled />
</el-form-item>
<el-form-item label="本地缓存">
<el-switch v-model="settings.localCacheEnabled" disabled />
</el-form-item>
<el-form-item label="缓存大小">
<el-input v-model="settings.localCacheSize" disabled />
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup>
import { reactive } from 'vue'
const settings = reactive({
prefix: 'captcha',
expireDefault: '120000ms',
localCacheEnabled: true,
localCacheSize: 20
})
</script>
@@ -0,0 +1,134 @@
<template>
<div class="sites">
<el-card>
<template #header>
<div class="card-header">
<span>站点管理</span>
<el-button type="primary" @click="showDialog()">新增站点</el-button>
</div>
</template>
<el-table :data="sites" v-loading="loading" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="站点名称" />
<el-table-column prop="domain" label="域名" />
<el-table-column prop="siteKey" label="Site Key">
<template #default="{ row }">
<el-text truncated>{{ row.siteKey }}</el-text>
</template>
</el-table-column>
<el-table-column prop="isEnabled" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.isEnabled ? 'success' : 'danger'">
{{ row.isEnabled ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button size="small" @click="showDialog(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="page"
:page-size="20"
:total="total"
layout="total, prev, pager, next"
@current-change="loadSites"
style="margin-top: 20px; justify-content: flex-end"
/>
</el-card>
<el-dialog v-model="dialogVisible" :title="editingSite ? '编辑站点' : '新增站点'" width="500px">
<el-form :model="form" label-width="100px">
<el-form-item label="站点名称">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="域名">
<el-input v-model="form.domain" />
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="form.isEnabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { siteApi } from '../../api'
import { ElMessage, ElMessageBox } from 'element-plus'
const sites = ref([])
const loading = ref(false)
const page = ref(1)
const total = ref(0)
const dialogVisible = ref(false)
const editingSite = ref(null)
const form = reactive({ name: '', domain: '', isEnabled: true })
onMounted(() => loadSites())
const loadSites = async () => {
loading.value = true
try {
const res = await siteApi.list({ page: page.value - 1, size: 20 })
sites.value = res.data?.content || []
total.value = res.data?.totalElements || 0
} finally {
loading.value = false
}
}
const showDialog = (site) => {
editingSite.value = site
if (site) {
form.name = site.name
form.domain = site.domain
form.isEnabled = site.isEnabled
} else {
form.name = ''
form.domain = ''
form.isEnabled = true
}
dialogVisible.value = true
}
const handleSave = async () => {
try {
if (editingSite.value) {
await siteApi.update(editingSite.value.id, { ...form, id: editingSite.value.id })
} else {
await siteApi.create(form)
}
ElMessage.success('保存成功')
dialogVisible.value = false
loadSites()
} catch (e) {
ElMessage.error('保存失败')
}
}
const handleDelete = async (id) => {
await ElMessageBox.confirm('确定删除该站点?', '提示', { type: 'warning' })
await siteApi.delete(id)
ElMessage.success('删除成功')
loadSites()
}
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
@@ -0,0 +1,96 @@
version: '3.8'
services:
postgres:
image: postgres:16-alpine
container_name: captcha-postgres
environment:
POSTGRES_DB: captcha_forge
POSTGRES_USER: pgsql
POSTGRES_PASSWORD: ${PG_PASSWORD:-tianai-captcha-pg}
ports:
- "5432:5432"
volumes:
- pg-data:/var/lib/postgresql/data
- ./sql/init.sql:/docker-entrypoint-initdb.d/01-init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pgsql -d captcha_forge"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: captcha-redis
ports:
- "6379:6379"
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
minio:
image: minio/minio:latest
container_name: captcha-minio
environment:
MINIO_ROOT_USER: ${MINIO_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD:-minioadmin}
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio-data:/data
command: server /data --console-address ":9001"
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 10s
timeout: 5s
retries: 5
captcha-platform:
build:
context: .
dockerfile: Dockerfile
container_name: captcha-platform
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/captcha_forge
SPRING_DATASOURCE_USERNAME: pgsql
SPRING_DATASOURCE_PASSWORD: ${PG_PASSWORD:-tianai-captcha-pg}
SPRING_DATA_REDIS_HOST: redis
SPRING_DATA_REDIS_PORT: 6379
MINIO_ENDPOINT: http://minio:9000
MINIO_ACCESS_KEY: ${MINIO_USER:-minioadmin}
MINIO_SECRET_KEY: ${MINIO_PASSWORD:-minioadmin}
JWT_SECRET: ${JWT_SECRET:-tianai-captcha-jwt-secret-key-must-be-at-least-256-bits-long-for-hs256}
ports:
- "18200:18200"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
minio:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:18200/api/admin/stats || exit 1"]
interval: 30s
timeout: 10s
retries: 3
captcha-admin:
build:
context: ../tianai-captcha-platform-ui
dockerfile: Dockerfile
container_name: captcha-admin
ports:
- "3000:80"
depends_on:
- captcha-platform
volumes:
pg-data:
redis-data:
minio-data:
+40
View File
@@ -51,6 +51,46 @@
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.5.7</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
+233
View File
@@ -0,0 +1,233 @@
DROP TABLE IF EXISTS verification_logs CASCADE;
DROP TABLE IF EXISTS track_samples CASCADE;
DROP TABLE IF EXISTS captcha_challenges CASCADE;
DROP TABLE IF EXISTS captcha_categories CASCADE;
DROP TABLE IF EXISTS sites CASCADE;
DROP TABLE IF EXISTS plans CASCADE;
DROP TABLE IF EXISTS users CASCADE;
DROP TABLE IF EXISTS announcements CASCADE;
DROP TABLE IF EXISTS captcha_ip_blacklist CASCADE;
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
email VARCHAR(128),
password VARCHAR(256) NOT NULL,
role VARCHAR(16) DEFAULT 'USER',
site_amount INT DEFAULT 0,
is_enabled BOOLEAN DEFAULT TRUE,
is_system BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE plans (
id SERIAL PRIMARY KEY,
name VARCHAR(64) NOT NULL,
description VARCHAR(256),
qps INT DEFAULT 10,
daily_limit INT DEFAULT 500,
captcha_types TEXT[] DEFAULT '{SLIDER,PUZZLE,TEXT_CLICK,ICON_CLICK,ICON_UNDERSTAND}',
custom_style BOOLEAN DEFAULT FALSE,
is_enabled BOOLEAN DEFAULT TRUE,
is_system BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE sites (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id),
name VARCHAR(64) NOT NULL,
domain VARCHAR(256),
favicon VARCHAR(512),
logo VARCHAR(512),
site_key UUID DEFAULT gen_random_uuid() UNIQUE,
secret_key UUID DEFAULT gen_random_uuid() UNIQUE,
verify_level VARCHAR(16) DEFAULT 'MEDIUM',
qps INT DEFAULT 10,
daily_limit INT DEFAULT 500,
captcha_types TEXT[] DEFAULT '{SLIDER,PUZZLE,TEXT_CLICK,ICON_CLICK,ICON_UNDERSTAND}',
plan_id INT REFERENCES plans(id),
plan_expire_at TIMESTAMPTZ,
is_enabled BOOLEAN DEFAULT TRUE,
rsa_public_key TEXT,
rsa_private_key TEXT,
aes_key VARCHAR(256),
signing_key VARCHAR(256),
track_validation_enabled BOOLEAN DEFAULT TRUE,
track_human_threshold FLOAT DEFAULT 0.5,
obfuscation_enabled BOOLEAN DEFAULT TRUE,
encryption_enabled BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE captcha_categories (
id SERIAL PRIMARY KEY,
name VARCHAR(64) NOT NULL,
label VARCHAR(64) NOT NULL,
items TEXT[] NOT NULL DEFAULT '{}',
is_enabled BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE captcha_challenges (
id SERIAL PRIMARY KEY,
category_id INT REFERENCES captcha_categories(id) ON DELETE CASCADE,
prompt TEXT NOT NULL,
correct_items TEXT[] NOT NULL DEFAULT '{}',
difficulty VARCHAR(16) DEFAULT 'MEDIUM',
is_enabled BOOLEAN DEFAULT TRUE,
use_count INT DEFAULT 0,
success_rate DOUBLE PRECISION DEFAULT 0.5,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE verification_logs (
id SERIAL PRIMARY KEY,
site_id INT REFERENCES sites(id),
site_key UUID,
captcha_type VARCHAR(32),
scene VARCHAR(64) DEFAULT 'default',
ip VARCHAR(45),
is_pass BOOLEAN,
behavior_score DOUBLE PRECISION,
risk_level VARCHAR(16),
cost_time INT,
captcha_id VARCHAR(128),
user_agent TEXT,
track_score FLOAT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE track_samples (
id SERIAL PRIMARY KEY,
site_id INT REFERENCES sites(id),
captcha_type VARCHAR(32),
is_human BOOLEAN,
ml_score DOUBLE PRECISION,
basic_pass BOOLEAN,
total_points INT,
total_duration BIGINT,
displacement_x FLOAT,
displacement_y FLOAT,
displacement_x_ratio FLOAT,
total_path_length DOUBLE PRECISION,
path_efficiency DOUBLE PRECISION,
avg_speed FLOAT,
max_speed FLOAT,
min_speed FLOAT,
speed_variance DOUBLE PRECISION,
speed_std_dev DOUBLE PRECISION,
speed_skewness DOUBLE PRECISION,
avg_acceleration FLOAT,
max_acceleration FLOAT,
min_acceleration FLOAT,
acceleration_variance DOUBLE PRECISION,
direction_changes INT,
y_direction_changes INT,
pauses INT,
start_offset DOUBLE PRECISION,
straightness DOUBLE PRECISION,
x_uniformity DOUBLE PRECISION,
y_uniformity DOUBLE PRECISION,
avg_point_interval FLOAT,
speed_phase_correlation DOUBLE PRECISION,
max_jump_distance DOUBLE PRECISION,
overshoot_ratio DOUBLE PRECISION,
track_json TEXT,
ip VARCHAR(45),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE announcements (
id SERIAL PRIMARY KEY,
title VARCHAR(256) NOT NULL,
content TEXT,
is_pinned BOOLEAN DEFAULT FALSE,
is_published BOOLEAN DEFAULT TRUE,
sort_order INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE captcha_ip_blacklist (
id SERIAL PRIMARY KEY,
ip VARCHAR(64) NOT NULL,
reason VARCHAR(256),
ban_until TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_sites_key ON sites(site_key);
CREATE INDEX idx_sites_user ON sites(user_id);
CREATE INDEX idx_challenges_category ON captcha_challenges(category_id);
CREATE INDEX idx_challenges_difficulty ON captcha_challenges(difficulty);
CREATE INDEX idx_logs_site ON verification_logs(site_id);
CREATE INDEX idx_logs_time ON verification_logs(created_at);
CREATE INDEX idx_logs_type ON verification_logs(captcha_type);
CREATE INDEX idx_logs_pass ON verification_logs(is_pass);
CREATE INDEX idx_ip_blacklist_ip ON captcha_ip_blacklist(ip);
CREATE INDEX idx_track_human ON track_samples(is_human);
CREATE INDEX idx_track_type ON track_samples(captcha_type);
CREATE INDEX idx_track_time ON track_samples(created_at);
CREATE INDEX idx_track_score ON track_samples(ml_score);
INSERT INTO users (username, email, password, role, site_amount, is_enabled, is_system)
VALUES ('admin', 'admin@captchaforge.local', '$2a$10$85QOiy3qD5KPuxyrrN/LcuZe3ty/OrTk6yEKAJEGtJKk97ukw2yPG', 'ADMIN', 999, TRUE, TRUE);
INSERT INTO plans (name, description, qps, daily_limit, is_enabled, is_system) VALUES
('免费套餐', '默认免费套餐', 5, 500, TRUE, TRUE),
('基础套餐', '适合中小站点', 20, 5000, TRUE, TRUE),
('专业套餐', '适合大型站点', 100, 50000, TRUE, TRUE);
INSERT INTO captcha_categories (name, label, items) VALUES
('animals', '动物', '{"🐶","🐱","🐭","🐹","🐰","🦊","🐻","🐼","🐨","🐯","🦁","🐮","🐷","🐸","🐵","🐔","🐧","🐦","🦅","🦆","🦉","🐴","🦄","🐝","🐛","🦋","🐌","🐞","🐢","🐍","🦎","🐙","🦑","🦐","🦀","🐠","🐟","🐬","🐳","🐊","🐘","🦏","🐪","🦒","🐕","🐈","🐓","🦃","🦚","🦜","🐇","🦔","🐺","🐗"}'),
('food', '食物饮品', '{"🍎","🍐","🍊","🍋","🍌","🍉","🍇","🍓","🍒","🍑","🥭","🍍","🥥","🥝","🍅","🍆","🥑","🥦","🌽","🥕","🍞","🧀","🍳","🍔","🍟","🍕","🌮","🍣","🍜","🍩","🍪","🎂","🍫","🍭","☕","🍵","🧃","🥤","🍺","🍷"}'),
('vehicles', '交通工具', '{"🚗","🚕","🚙","🚌","🏎","🚓","🚑","🚒","🚐","🚚","🚛","🚜","🚲","🛵","🏍","🚨","🚔","🚡","🚠","🚃","🚋","🚄","🚅","🚂","✈️","🛩","🚀","🛸","🚁","⛵","🚤","🛳","⛴","🚢"}'),
('nature', '天气自然', '{"☀️","🌤","⛅","🌥","☁️","🌦","🌧","⛈","🌩","🌨","❄️","☃️","🌪","🌈","🌊","💧","🔥","⭐","🌟","✨","⚡","☄️","🌸","🌺","🌻","🌹","🌷","🌱","🌿","🍀","🍁","🍂","🍃","🌴","🌵"}'),
('sports', '运动娱乐', '{"⚽","🏀","🏈","⚾","🎾","🏐","🏉","🎱","🏓","🏸","🏒","🏑","🥍","🎯","🎳","🎮","🎲","♟","🧩","🪀","🪁","🎪","🤹","🎭","🎨","🎬","🎤","🎧","🎹","🥁","🎸","🎻"}'),
('buildings', '建筑地点', '{"🏠","🏡","🏢","🏣","🏤","🏥","🏦","🏨","🏩","🏪","🏫","🏬","🏭","🏯","🏰","💒","🗼","🗽","⛪","🕌","🛕","🕍","⛩","🕋","⛲","⛺","🏕"}'),
('objects', '电子物品', '{"⌚","📱","💻","⌨️","🖥","🖨","🖱","🖲","💾","💿","📷","📹","🎥","📞","📺","📻","🔋","🔌","💡","🔦","🕯","🔑","🔒","🔓","📧","📮","📦","📋","📁","✏️","🖊","🖋","✒️","🖌","📝","🔍","📎","📐","📌","✂️","🧲","🔧","🔨","⚙️","💊","💉","🩺","🧬","🔭","🔬","🧪"}'),
('gestures', '手势动作', '{"👋","🤚","🖐","✋","🖖","👌","🤌","🤏","✌","🤞","🤟","🤘","🤙","👈","👉","👆","👇","☝️","👍","👎","✊","👊","🤛","🤜","👏","🙌","👐","🤲","🤝","🙏","✍️","💅","🤳","💪"}');
INSERT INTO captcha_challenges (category_id, prompt, correct_items, difficulty) VALUES
(1, '请点击所有的猫科动物', '{"🐱","🐯","🦁","🐈"}', 'MEDIUM'),
(1, '请点击所有的犬科动物', '{"🐶","🐺","🐕"}', 'MEDIUM'),
(1, '请点击所有的鸟类', '{"🐔","🐧","🐦","🦅","🦆","🦉","🦜","🦚","🦃"}', 'MEDIUM'),
(1, '请点击所有的水生动物', '{"🐙","🦑","🦐","🦀","🐠","🐟","🐬","🐳"}', 'MEDIUM'),
(1, '请点击所有的昆虫', '{"🐝","🐛","🦋","🐌","🐞","🦟"}', 'MEDIUM'),
(2, '请点击所有的水果', '{"🍎","🍐","🍊","🍋","🍌","🍉","🍇","🍓","🍒","🍑","🥭","🍍","🥝"}', 'MEDIUM'),
(2, '请点击所有的蔬菜', '{"🍅","🍆","🥑","🥦","🌽","🥕"}', 'MEDIUM'),
(2, '请点击所有的饮品', '{"☕","🍵","🧃","🥤","🍺","🍷"}', 'MEDIUM'),
(2, '请点击所有的甜点', '{"🍩","🍪","🎂","🍫","🍭"}', 'MEDIUM'),
(3, '请点击所有的汽车', '{"🚗","🚕","🚙","🏎","🚓","🚑","🚒","🚐"}', 'MEDIUM'),
(3, '请点击所有的飞行器', '{"✈️","🛩","🚀","🛸","🚁"}', 'MEDIUM'),
(3, '请点击所有的船只', '{"⛵","🚤","🛳","⛴","🚢"}', 'MEDIUM'),
(4, '请点击所有与降水相关的', '{"🌦","🌧","⛈","🌩","🌨"}', 'MEDIUM'),
(4, '请点击所有的花卉', '{"🌸","🌺","🌻","🌹","🌷"}', 'MEDIUM'),
(4, '请点击所有的天体', '{"☀️","⭐","🌟","✨","⚡","☄️"}', 'MEDIUM'),
(5, '请点击所有的球类运动', '{"⚽","🏀","🏈","⚾","🎾","🏐","🏉"}', 'MEDIUM'),
(5, '请点击所有的音乐相关', '{"🎤","🎧","🎹","🥁","🎸","🎻"}', 'MEDIUM'),
(5, '请点击所有的棋牌游戏', '{"🎲","♟","🧩","🎮","🎯","🎱"}', 'MEDIUM'),
(1, '请点击所有的动物', '{"🐶","🐱","🐭","🐹","🐰","🦊","🐻","🐼","🐨","🐯","🦁","🐮","🐷","🐸","🐵","🐔","🐧","🐦","🦅","🦆","🦉","🐴","🦄","🐝","🐛","🦋","🐌","🐞","🐢","🐍","🦎","🐙","🦑","🦐","🦀","🐠","🐟","🐬","🐳","🐊","🐘","🦏","🐪","🦒","🐕","🐈","🐓","🦃","🦚","🦜","🐇","🦔","🐺","🐗"}', 'LOW'),
(2, '请点击所有的食物饮品', '{"🍎","🍐","🍊","🍋","🍌","🍉","🍇","🍓","🍒","🍑","🥭","🍍","🥥","🥝","🍅","🍆","🥑","🥦","🌽","🥕","🍞","🧀","🍳","🍔","🍟","🍕","🌮","🍣","🍜","🍩","🍪","🎂","🍫","🍭","☕","🍵","🧃","🥤","🍺","🍷"}', 'LOW'),
(3, '请点击所有的交通工具', '{"🚗","🚕","🚙","🚌","🏎","🚓","🚑","🚒","🚐","🚚","🚛","🚜","🚲","🛵","🏍","🚨","🚔","🚡","🚠","🚃","🚋","🚄","🚅","🚂","✈️","🛩","🚀","🛸","🚁","⛵","🚤","🛳","⛴","🚢"}', 'LOW'),
(4, '请点击所有的天气自然', '{"☀️","🌤","⛅","🌥","☁️","🌦","🌧","⛈","🌩","🌨","❄️","☃️","🌪","🌈","🌊","💧","🔥","⭐","🌟","✨","⚡","☄️","🌸","🌺","🌻","🌹","🌷","🌱","🌿","🍀","🍁","🍂","🍃","🌴","🌵"}', 'LOW');
INSERT INTO announcements (title, content, is_pinned, is_published, sort_order) VALUES
('tianai-captcha-enhanced 2.0.0 上线', '# tianai-captcha-enhanced
基于 tianai-captcha 开源版的增强版行为验证码平台
## 特性
- 16种验证码类型
- ML轨迹校验器(28维特征+14条规则)
- 对抗扰动防YOLO
- 行为风控引擎
- 端到端加密(AES-256+RSA-4096)
- 背景乱序/正弦扭曲/噪声注入
- IP黑名单+滑动窗口限流', TRUE, TRUE, 0);
@@ -0,0 +1,100 @@
package cloud.tianai.captcha.platform.aspect;
import cloud.tianai.captcha.platform.entity.CaptchaLog;
import cloud.tianai.captcha.platform.mapper.CaptchaLogRepository;
import cloud.tianai.captcha.platform.service.RealtimeStatsService;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Aspect
@Component
public class CaptchaLogAspect {
private static final Logger log = LoggerFactory.getLogger(CaptchaLogAspect.class);
private final CaptchaLogRepository logRepository;
private final RealtimeStatsService statsService;
public CaptchaLogAspect(CaptchaLogRepository logRepository, RealtimeStatsService statsService) {
this.logRepository = logRepository;
this.statsService = statsService;
}
@Around("execution(* cloud.tianai.captcha.platform.controller.CaptchaApiController.*(..))")
public Object logCaptchaRequest(ProceedingJoinPoint joinPoint) throws Throwable {
long startTime = System.currentTimeMillis();
Object result = joinPoint.proceed();
long duration = System.currentTimeMillis() - startTime;
try {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String methodName = signature.getName();
String ip = extractIp();
String siteKey = extractSiteKey(joinPoint.getArgs());
Map<String, Object> stats = new ConcurrentHashMap<>();
stats.put("method", methodName);
stats.put("duration", duration);
stats.put("ip", ip);
stats.put("siteKey", siteKey);
if ("verify".equals(methodName) && result instanceof cloud.tianai.captcha.common.response.ApiResponse<?> response) {
stats.put("success", response.isSuccess());
stats.put("captchaType", "VERIFY");
statsService.recordVerifyAttempt(response.isSuccess(), ip, siteKey);
} else if ("generate".equals(methodName)) {
stats.put("success", true);
stats.put("captchaType", "GENERATE");
statsService.recordGenerate(ip, siteKey);
}
statsService.incrementTotalRequests();
log.debug("[CAPTCHA-AOP] {} took {}ms ip={}", methodName, duration, ip);
} catch (Exception e) {
log.warn("[CAPTCHA-AOP] Failed to log stats: {}", e.getMessage());
}
return result;
}
private String extractIp() {
try {
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs != null) {
HttpServletRequest request = attrs.getRequest();
String xff = request.getHeader("X-Forwarded-For");
if (xff != null && !xff.isEmpty()) {
return xff.split(",")[0].trim();
}
String xreal = request.getHeader("X-Real-IP");
if (xreal != null && !xreal.isEmpty()) {
return xreal;
}
return request.getRemoteAddr();
}
} catch (Exception e) {
// ignore
}
return "unknown";
}
private String extractSiteKey(Object[] args) {
for (Object arg : args) {
if (arg instanceof String s && s != null && s.length() > 10) {
return s;
}
}
return "unknown";
}
}
@@ -0,0 +1,52 @@
package cloud.tianai.captcha.platform.config;
import cloud.tianai.captcha.platform.service.JwtService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Collections;
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
public JwtAuthenticationFilter(JwtService jwtService) {
this.jwtService = jwtService;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
try {
if (jwtService.validateToken(token)) {
String username = jwtService.getUsernameFromToken(token);
Integer userId = jwtService.getUserIdFromToken(token);
String role = jwtService.getRoleFromToken(token);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userId,
null,
Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + role))
);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception e) {
SecurityContextHolder.clearContext();
}
}
filterChain.doFilter(request, response);
}
}
@@ -0,0 +1,67 @@
package cloud.tianai.captcha.platform.config;
import cloud.tianai.captcha.platform.service.JwtService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import java.util.List;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final JwtService jwtService;
public SecurityConfig(JwtService jwtService) {
this.jwtService = jwtService;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter(jwtService);
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/captcha/**").permitAll()
.requestMatchers("/api/admin/**").authenticated()
.anyRequest().permitAll()
)
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("*"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
@@ -0,0 +1,101 @@
package cloud.tianai.captcha.platform.controller;
import cloud.tianai.captcha.common.response.ApiResponse;
import cloud.tianai.captcha.platform.entity.User;
import cloud.tianai.captcha.platform.mapper.UserRepository;
import cloud.tianai.captcha.platform.service.JwtService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final UserRepository userRepository;
private final JwtService jwtService;
private final PasswordEncoder passwordEncoder;
public AuthController(UserRepository userRepository, JwtService jwtService, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.jwtService = jwtService;
this.passwordEncoder = passwordEncoder;
}
@PostMapping("/login")
public ApiResponse<?> login(@RequestBody Map<String, String> body) {
String username = body.get("username");
String password = body.get("password");
if (username == null || password == null) {
return ApiResponse.of(400, "missing_params", null);
}
User user = userRepository.findByUsername(username).orElse(null);
if (user == null || !passwordEncoder.matches(password, user.getPasswordHash())) {
return ApiResponse.of(401, "invalid_credentials", null);
}
if (!user.getIsEnabled()) {
return ApiResponse.of(403, "account_disabled", null);
}
String token = jwtService.generateToken(user.getId(), user.getUsername(), user.getRole());
Map<String, Object> data = new HashMap<>();
data.put("token", token);
data.put("username", user.getUsername());
data.put("role", user.getRole());
return ApiResponse.ofSuccess(data);
}
@PostMapping("/register")
public ApiResponse<?> register(@RequestBody Map<String, String> body) {
String username = body.get("username");
String password = body.get("password");
String email = body.get("email");
if (username == null || password == null) {
return ApiResponse.of(400, "missing_params", null);
}
if (userRepository.existsByUsername(username)) {
return ApiResponse.of(409, "username_exists", null);
}
User user = new User();
user.setUsername(username);
user.setPasswordHash(passwordEncoder.encode(password));
user.setEmail(email);
user.setRole("USER");
userRepository.save(user);
return ApiResponse.ofSuccess("Registration successful");
}
@GetMapping("/me")
public ApiResponse<?> getCurrentUser(@RequestHeader("Authorization") String authHeader) {
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
return ApiResponse.of(401, "unauthorized", null);
}
String token = authHeader.substring(7);
if (!jwtService.validateToken(token)) {
return ApiResponse.of(401, "invalid_token", null);
}
Integer userId = jwtService.getUserIdFromToken(token);
User user = userRepository.findById(userId).orElse(null);
if (user == null) {
return ApiResponse.of(404, "user_not_found", null);
}
Map<String, Object> data = new HashMap<>();
data.put("id", user.getId());
data.put("username", user.getUsername());
data.put("email", user.getEmail());
data.put("role", user.getRole());
return ApiResponse.ofSuccess(data);
}
}
@@ -0,0 +1,60 @@
package cloud.tianai.captcha.platform.controller;
import cloud.tianai.captcha.common.response.ApiResponse;
import cloud.tianai.captcha.platform.entity.Plan;
import cloud.tianai.captcha.platform.mapper.PlanRepository;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/admin/plans")
public class PlanController {
private final PlanRepository planRepository;
public PlanController(PlanRepository planRepository) {
this.planRepository = planRepository;
}
@GetMapping
public ApiResponse<List<Plan>> listPlans() {
return ApiResponse.ofSuccess(planRepository.findAll());
}
@GetMapping("/active")
public ApiResponse<List<Plan>> listActivePlans() {
return ApiResponse.ofSuccess(planRepository.findByIsActiveTrue());
}
@GetMapping("/{id}")
public ApiResponse<Plan> getPlan(@PathVariable Integer id) {
Plan plan = planRepository.findById(id).orElse(null);
if (plan == null) {
return ApiResponse.of(404, "plan_not_found", null);
}
return ApiResponse.ofSuccess(plan);
}
@PostMapping
public ApiResponse<Plan> createPlan(@RequestBody Plan plan) {
plan.setId(null);
return ApiResponse.ofSuccess(planRepository.save(plan));
}
@PutMapping("/{id}")
public ApiResponse<Plan> updatePlan(@PathVariable Integer id, @RequestBody Plan plan) {
Plan existing = planRepository.findById(id).orElse(null);
if (existing == null) {
return ApiResponse.of(404, "plan_not_found", null);
}
plan.setId(id);
return ApiResponse.ofSuccess(planRepository.save(plan));
}
@DeleteMapping("/{id}")
public ApiResponse<?> deletePlan(@PathVariable Integer id) {
planRepository.deleteById(id);
return ApiResponse.ofSuccess();
}
}
@@ -0,0 +1,60 @@
package cloud.tianai.captcha.platform.controller;
import cloud.tianai.captcha.common.response.ApiResponse;
import cloud.tianai.captcha.platform.service.MinioStorageService;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/admin/resources")
public class ResourceController {
private final MinioStorageService storageService;
public ResourceController(MinioStorageService storageService) {
this.storageService = storageService;
}
@PostMapping("/upload")
public ApiResponse<?> uploadFile(
@RequestParam("file") MultipartFile file,
@RequestParam(value = "prefix", defaultValue = "general") String prefix) {
try {
String url = storageService.uploadFile(file, prefix);
Map<String, Object> data = new HashMap<>();
data.put("url", url);
data.put("filename", file.getOriginalFilename());
data.put("size", file.getSize());
return ApiResponse.ofSuccess(data);
} catch (Exception e) {
return ApiResponse.of(500, "upload_failed", null);
}
}
@DeleteMapping
public ApiResponse<?> deleteFile(@RequestParam String objectName) {
try {
storageService.deleteFile(objectName);
return ApiResponse.ofSuccess();
} catch (Exception e) {
return ApiResponse.of(500, "delete_failed", null);
}
}
@GetMapping("/presigned-url")
public ApiResponse<?> getPresignedUrl(
@RequestParam String objectName,
@RequestParam(defaultValue = "3600") int expirySeconds) {
try {
String url = storageService.getPresignedUrl(objectName, expirySeconds);
Map<String, Object> data = new HashMap<>();
data.put("url", url);
return ApiResponse.ofSuccess(data);
} catch (Exception e) {
return ApiResponse.of(500, "url_generation_failed", null);
}
}
}
@@ -0,0 +1,78 @@
package cloud.tianai.captcha.platform.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
@Entity
@Table(name = "plans")
public class Plan {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(length = 64, nullable = false)
private String name;
@Column(length = 256)
private String description;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal price;
@Column(name = "daily_quota", nullable = false)
private Integer dailyQuota;
@Column(name = "qps_limit", nullable = false)
private Integer qpsLimit;
@Column(name = "site_limit", nullable = false)
private Integer siteLimit;
@Column(name = "features", columnDefinition = "TEXT[]")
private java.util.Set<String> features;
@Column(name = "is_active")
private Boolean isActive = true;
@Column(name = "created_at")
private OffsetDateTime createdAt;
@Column(name = "updated_at")
private OffsetDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = OffsetDateTime.now();
updatedAt = OffsetDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = OffsetDateTime.now();
}
public Integer getId() { return id; }
public void setId(Integer id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public BigDecimal getPrice() { return price; }
public void setPrice(BigDecimal price) { this.price = price; }
public Integer getDailyQuota() { return dailyQuota; }
public void setDailyQuota(Integer dailyQuota) { this.dailyQuota = dailyQuota; }
public Integer getQpsLimit() { return qpsLimit; }
public void setQpsLimit(Integer qpsLimit) { this.qpsLimit = qpsLimit; }
public Integer getSiteLimit() { return siteLimit; }
public void setSiteLimit(Integer siteLimit) { this.siteLimit = siteLimit; }
public java.util.Set<String> getFeatures() { return features; }
public void setFeatures(java.util.Set<String> features) { this.features = features; }
public Boolean getIsActive() { return isActive; }
public void setIsActive(Boolean isActive) { this.isActive = isActive; }
public OffsetDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
public OffsetDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; }
}
@@ -0,0 +1,62 @@
package cloud.tianai.captcha.platform.entity;
import jakarta.persistence.*;
import java.time.OffsetDateTime;
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(length = 64, unique = true, nullable = false)
private String username;
@Column(length = 256, nullable = false)
private String passwordHash;
@Column(length = 128)
private String email;
@Column(length = 32)
private String role = "USER";
@Column(name = "is_enabled")
private Boolean isEnabled = true;
@Column(name = "created_at")
private OffsetDateTime createdAt;
@Column(name = "updated_at")
private OffsetDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = OffsetDateTime.now();
updatedAt = OffsetDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = OffsetDateTime.now();
}
public Integer getId() { return id; }
public void setId(Integer id) { this.id = id; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPasswordHash() { return passwordHash; }
public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getRole() { return role; }
public void setRole(String role) { this.role = role; }
public Boolean getIsEnabled() { return isEnabled; }
public void setIsEnabled(Boolean isEnabled) { this.isEnabled = isEnabled; }
public OffsetDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
public OffsetDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; }
}
@@ -2,7 +2,31 @@ package cloud.tianai.captcha.platform.mapper;
import cloud.tianai.captcha.platform.entity.CaptchaLog;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.OffsetDateTime;
import java.util.List;
public interface CaptchaLogRepository extends JpaRepository<CaptchaLog, Integer> {
long countByIsPass(Boolean isPass);
long countBySiteIdAndCreatedAtBetween(Integer siteId, OffsetDateTime start, OffsetDateTime end);
long countBySiteIdAndIsPassAndCreatedAtBetween(Integer siteId, Boolean isPass, OffsetDateTime start, OffsetDateTime end);
long countBySiteIdAndCaptchaTypeAndCreatedAtBetween(Integer siteId, String captchaType, OffsetDateTime start, OffsetDateTime end);
long countByIpAndCreatedAtBetween(String ip, OffsetDateTime start, OffsetDateTime end);
@Query("SELECT l.captchaType, COUNT(l) FROM CaptchaLog l WHERE l.siteId = :siteId AND l.createdAt BETWEEN :start AND :end GROUP BY l.captchaType")
List<Object[]> countByTypeGrouped(@Param("siteId") Integer siteId, @Param("start") OffsetDateTime start, @Param("end") OffsetDateTime end);
@Query("SELECT l.ip, COUNT(l) FROM CaptchaLog l WHERE l.createdAt BETWEEN :start AND :end GROUP BY l.ip ORDER BY COUNT(l) DESC")
List<Object[]> topIpStats(@Param("start") OffsetDateTime start, @Param("end") OffsetDateTime end);
@Query("SELECT FUNCTION('DATE', l.createdAt), COUNT(l), SUM(CASE WHEN l.isPass = true THEN 1 ELSE 0 END) FROM CaptchaLog l WHERE l.siteId = :siteId AND l.createdAt BETWEEN :start AND :end GROUP BY FUNCTION('DATE', l.createdAt) ORDER BY FUNCTION('DATE', l.createdAt)")
List<Object[]> dailyStats(@Param("siteId") Integer siteId, @Param("start") OffsetDateTime start, @Param("end") OffsetDateTime end);
List<CaptchaLog> findTop100BySiteIdOrderByCreatedAtDesc(Integer siteId);
}
@@ -0,0 +1,10 @@
package cloud.tianai.captcha.platform.mapper;
import cloud.tianai.captcha.platform.entity.Plan;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface PlanRepository extends JpaRepository<Plan, Integer> {
List<Plan> findByIsActiveTrue();
}
@@ -0,0 +1,11 @@
package cloud.tianai.captcha.platform.mapper;
import cloud.tianai.captcha.platform.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Integer> {
Optional<User> findByUsername(String username);
Boolean existsByUsername(String username);
}
@@ -0,0 +1,97 @@
package cloud.tianai.captcha.platform.service;
import cloud.tianai.captcha.platform.entity.CaptchaLog;
import cloud.tianai.captcha.platform.mapper.CaptchaLogRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class AnomalyDetectionService {
private static final Logger log = LoggerFactory.getLogger(AnomalyDetectionService.class);
private static final double FAIL_RATE_THRESHOLD = 0.7;
private static final int IP_FREQ_THRESHOLD = 100;
private final CaptchaLogRepository logRepository;
private final Map<String, AnomalyAlert> activeAlerts = new ConcurrentHashMap<>();
public AnomalyDetectionService(CaptchaLogRepository logRepository) {
this.logRepository = logRepository;
}
@Scheduled(fixedRate = 60000)
public void detectAnomalies() {
try {
OffsetDateTime oneHourAgo = OffsetDateTime.now(ZoneOffset.UTC).minusHours(1);
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
List<Object[]> topIps = logRepository.topIpStats(oneHourAgo, now);
for (Object[] row : topIps) {
String ip = (String) row[0];
long count = ((Number) row[1]).longValue();
if (count > IP_FREQ_THRESHOLD) {
triggerAlert("IP_FREQ", ip, "High request frequency: " + count + " requests/hour");
}
}
List<Object[]> typeStats = logRepository.countByTypeGrouped(null, oneHourAgo, now);
for (Object[] row : typeStats) {
String type = (String) row[0];
long total = ((Number) row[1]).longValue();
if (total > 100) {
long fails = logRepository.countByIsPass(false);
double failRate = (double) fails / total;
if (failRate > FAIL_RATE_THRESHOLD) {
triggerAlert("HIGH_FAIL_RATE", type, "Fail rate: " + String.format("%.1f%%", failRate * 100));
}
}
}
} catch (Exception e) {
log.warn("[ANOMALY] Detection failed: {}", e.getMessage());
}
}
private void triggerAlert(String type, String target, String message) {
String alertKey = type + ":" + target;
if (!activeAlerts.containsKey(alertKey)) {
AnomalyAlert alert = new AnomalyAlert(type, target, message);
activeAlerts.put(alertKey, alert);
log.warn("[ANOMALY] Alert triggered: {} - {} - {}", type, target, message);
}
}
public List<AnomalyAlert> getActiveAlerts() {
return new ArrayList<>(activeAlerts.values());
}
public void clearAlert(String alertKey) {
activeAlerts.remove(alertKey);
}
public static class AnomalyAlert {
private final String type;
private final String target;
private final String message;
private final long timestamp;
public AnomalyAlert(String type, String target, String message) {
this.type = type;
this.target = target;
this.message = message;
this.timestamp = System.currentTimeMillis();
}
public String getType() { return type; }
public String getTarget() { return target; }
public String getMessage() { return message; }
public long getTimestamp() { return timestamp; }
}
}
@@ -0,0 +1,99 @@
package cloud.tianai.captcha.platform.service;
import cloud.tianai.captcha.platform.entity.CaptchaLog;
import cloud.tianai.captcha.platform.mapper.CaptchaLogRepository;
import org.springframework.stereotype.Service;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class HistoryStatsService {
private final CaptchaLogRepository logRepository;
public HistoryStatsService(CaptchaLogRepository logRepository) {
this.logRepository = logRepository;
}
public Map<String, Object> getDailyStats(Integer siteId, int days) {
OffsetDateTime start = OffsetDateTime.now(ZoneOffset.UTC).minusDays(days);
OffsetDateTime end = OffsetDateTime.now(ZoneOffset.UTC);
List<Object[]> rows = logRepository.dailyStats(siteId, start, end);
List<String> dates = new ArrayList<>();
List<Long> totals = new ArrayList<>();
List<Long> successes = new ArrayList<>();
for (Object[] row : rows) {
dates.add(String.valueOf(row[0]));
totals.add(((Number) row[1]).longValue());
successes.add(((Number) row[2]).longValue());
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("dates", dates);
result.put("totals", totals);
result.put("successes", successes);
return result;
}
public Map<String, Object> getTypeDistribution(Integer siteId, int days) {
OffsetDateTime start = OffsetDateTime.now(ZoneOffset.UTC).minusDays(days);
OffsetDateTime end = OffsetDateTime.now(ZoneOffset.UTC);
List<Object[]> rows = logRepository.countByTypeGrouped(siteId, start, end);
Map<String, Long> distribution = new LinkedHashMap<>();
long total = 0;
for (Object[] row : rows) {
String type = (String) row[0];
long count = ((Number) row[1]).longValue();
distribution.put(type, count);
total += count;
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("distribution", distribution);
result.put("total", total);
return result;
}
public List<Map<String, Object>> getTopIps(int days, int limit) {
OffsetDateTime start = OffsetDateTime.now(ZoneOffset.UTC).minusDays(days);
OffsetDateTime end = OffsetDateTime.now(ZoneOffset.UTC);
List<Object[]> rows = logRepository.topIpStats(start, end);
return rows.stream()
.limit(limit)
.map(row -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("ip", row[0]);
item.put("count", ((Number) row[1]).longValue());
return item;
})
.collect(Collectors.toList());
}
public Map<String, Object> getHourlyDistribution(Integer siteId, int days) {
OffsetDateTime start = OffsetDateTime.now(ZoneOffset.UTC).minusDays(days);
OffsetDateTime end = OffsetDateTime.now(ZoneOffset.UTC);
List<CaptchaLog> logs = logRepository.findTop100BySiteIdOrderByCreatedAtDesc(siteId);
Map<Integer, Long> hourly = logs.stream()
.filter(l -> l.getCreatedAt() != null && l.getCreatedAt().isAfter(start))
.collect(Collectors.groupingBy(
l -> l.getCreatedAt().getHour(),
Collectors.counting()
));
Map<String, Object> result = new LinkedHashMap<>();
result.put("hours", hourly);
return result;
}
}
@@ -0,0 +1,72 @@
package cloud.tianai.captcha.platform.service;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@Service
public class JwtService {
@Value("${jwt.secret:tianai-captcha-secret-key-must-be-at-least-256-bits-long!!}")
private String secret;
@Value("${jwt.expiration:86400000}")
private long expiration;
private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
public String generateToken(Integer userId, String username, String role) {
Map<String, Object> claims = new HashMap<>();
claims.put("userId", userId);
claims.put("username", username);
claims.put("role", role);
return Jwts.builder()
.setClaims(claims)
.setSubject(username)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
public Claims parseToken(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getBody();
}
public Boolean validateToken(String token) {
try {
Claims claims = parseToken(token);
return !claims.getExpiration().before(new Date());
} catch (Exception e) {
return false;
}
}
public String getUsernameFromToken(String token) {
return parseToken(token).getSubject();
}
public Integer getUserIdFromToken(String token) {
return (Integer) parseToken(token).get("userId");
}
public String getRoleFromToken(String token) {
return (String) parseToken(token).get("role");
}
}
@@ -0,0 +1,106 @@
package cloud.tianai.captcha.platform.service;
import io.minio.*;
import io.minio.http.Method;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import jakarta.annotation.PostConstruct;
import java.io.InputStream;
import java.util.UUID;
@Service
public class MinioStorageService {
@Value("${minio.endpoint:http://localhost:9000}")
private String endpoint;
@Value("${minio.access-key:minioadmin}")
private String accessKey;
@Value("${minio.secret-key:minioadmin}")
private String secretKey;
@Value("${minio.bucket:captcha-resources}")
private String bucket;
private MinioClient minioClient;
@PostConstruct
public void init() {
try {
minioClient = MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKey, secretKey)
.build();
// 确保bucket存在
boolean exists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucket).build());
if (!exists) {
minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
}
} catch (Exception e) {
System.err.println("Failed to initialize MinIO client: " + e.getMessage());
}
}
public String uploadFile(MultipartFile file, String prefix) {
try {
String objectName = prefix + "/" + UUID.randomUUID().toString() + getExtension(file.getOriginalFilename());
minioClient.putObject(PutObjectArgs.builder()
.bucket(bucket)
.object(objectName)
.stream(file.getInputStream(), file.getSize(), -1)
.contentType(file.getContentType())
.build());
return endpoint + "/" + bucket + "/" + objectName;
} catch (Exception e) {
throw new RuntimeException("Failed to upload file to MinIO", e);
}
}
public InputStream downloadFile(String objectName) {
try {
return minioClient.getObject(GetObjectArgs.builder()
.bucket(bucket)
.object(objectName)
.build());
} catch (Exception e) {
throw new RuntimeException("Failed to download file from MinIO", e);
}
}
public void deleteFile(String objectName) {
try {
minioClient.removeObject(RemoveObjectArgs.builder()
.bucket(bucket)
.object(objectName)
.build());
} catch (Exception e) {
throw new RuntimeException("Failed to delete file from MinIO", e);
}
}
public String getPresignedUrl(String objectName, int expirySeconds) {
try {
return minioClient.getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder()
.method(Method.GET)
.bucket(bucket)
.object(objectName)
.expiry(expirySeconds)
.build());
} catch (Exception e) {
throw new RuntimeException("Failed to generate presigned URL", e);
}
}
private String getExtension(String filename) {
if (filename == null) return ".bin";
int dotIndex = filename.lastIndexOf('.');
return dotIndex >= 0 ? filename.substring(dotIndex) : ".bin";
}
}
@@ -0,0 +1,165 @@
package cloud.tianai.captcha.platform.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
@Service
public class RealtimeStatsService {
private static final Logger log = LoggerFactory.getLogger(RealtimeStatsService.class);
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyyMMdd");
private final StringRedisTemplate redisTemplate;
private final AtomicLong totalRequests = new AtomicLong(0);
private final AtomicLong totalGenerate = new AtomicLong(0);
private final AtomicLong totalVerify = new AtomicLong(0);
private final AtomicLong totalSuccess = new AtomicLong(0);
private final AtomicLong totalFail = new AtomicLong(0);
private final Map<String, AtomicLong> ipRequestCounts = new ConcurrentHashMap<>();
private final Map<String, AtomicLong> siteRequestCounts = new ConcurrentHashMap<>();
public RealtimeStatsService(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void incrementTotalRequests() {
totalRequests.incrementAndGet();
incrementRedis("stats:total:" + todayKey());
}
public void recordGenerate(String ip, String siteKey) {
totalGenerate.incrementAndGet();
incrementRedis("stats:generate:" + todayKey());
incrementIpCount(ip);
incrementSiteCount(siteKey);
}
public void recordVerifyAttempt(boolean success, String ip, String siteKey) {
if (success) {
totalSuccess.incrementAndGet();
incrementRedis("stats:success:" + todayKey());
} else {
totalFail.incrementAndGet();
incrementRedis("stats:fail:" + todayKey());
}
totalVerify.incrementAndGet();
incrementRedis("stats:verify:" + todayKey());
incrementIpCount(ip);
incrementSiteCount(siteKey);
}
public Map<String, Object> getRealtimeStats() {
Map<String, Object> stats = new LinkedHashMap<>();
String key = todayKey();
stats.put("date", LocalDate.now().toString());
stats.put("totalRequests", getTotalRedis("stats:total:" + key));
stats.put("generateCount", getTotalRedis("stats:generate:" + key));
stats.put("verifyCount", getTotalRedis("stats:verify:" + key));
stats.put("successCount", getTotalRedis("stats:success:" + key));
stats.put("failCount", getTotalRedis("stats:fail:" + key));
long total = getTotalRedis("stats:verify:" + key);
long success = getTotalRedis("stats:success:" + key);
stats.put("passRate", total > 0 ? String.format("%.2f%%", success * 100.0 / total) : "0%");
return stats;
}
public Map<String, Object> getDashboardStats(Integer siteId, int days) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("realtime", getRealtimeStats());
Map<String, Long> dailyTotals = new LinkedHashMap<>();
Map<String, Long> dailySuccess = new LinkedHashMap<>();
for (int i = days - 1; i >= 0; i--) {
String dayKey = LocalDate.now().minusDays(i).format(DATE_FMT);
dailyTotals.put(dayKey, getTotalRedis("stats:verify:" + dayKey));
dailySuccess.put(dayKey, getTotalRedis("stats:success:" + dayKey));
}
result.put("dailyTotals", dailyTotals);
result.put("dailySuccess", dailySuccess);
List<Map<String, Object>> topIps = new ArrayList<>();
ipRequestCounts.entrySet().stream()
.sorted((a, b) -> Long.compare(b.getValue().get(), a.getValue().get()))
.limit(10)
.forEach(e -> {
Map<String, Object> ipStat = new LinkedHashMap<>();
ipStat.put("ip", e.getKey());
ipStat.put("count", e.getValue().get());
topIps.add(ipStat);
});
result.put("topIps", topIps);
List<Map<String, Object>> topSites = new ArrayList<>();
siteRequestCounts.entrySet().stream()
.sorted((a, b) -> Long.compare(b.getValue().get(), a.getValue().get()))
.limit(10)
.forEach(e -> {
Map<String, Object> siteStat = new LinkedHashMap<>();
siteStat.put("siteKey", e.getKey());
siteStat.put("count", e.getValue().get());
topSites.add(siteStat);
});
result.put("topSites", topSites);
return result;
}
private void incrementRedis(String key) {
try {
redisTemplate.opsForValue().increment(key);
redisTemplate.expire(key, java.time.Duration.ofDays(35));
} catch (Exception e) {
log.debug("Redis increment failed for {}: {}", key, e.getMessage());
}
}
private long getTotalRedis(String key) {
try {
String val = redisTemplate.opsForValue().get(key);
return val != null ? Long.parseLong(val) : 0;
} catch (Exception e) {
log.debug("Redis get failed for {}: {}", key, e.getMessage());
return 0;
}
}
private void incrementIpCount(String ip) {
if (ip != null) {
ipRequestCounts.computeIfAbsent(ip, k -> new AtomicLong(0)).incrementAndGet();
}
}
private void incrementSiteCount(String siteKey) {
if (siteKey != null) {
siteRequestCounts.computeIfAbsent(siteKey, k -> new AtomicLong(0)).incrementAndGet();
}
}
private String todayKey() {
return LocalDate.now().format(DATE_FMT);
}
@Scheduled(fixedRate = 300000)
public void syncToRedis() {
try {
String key = todayKey();
redisTemplate.opsForValue().set("stats:total:" + key, String.valueOf(totalRequests.get()));
redisTemplate.opsForValue().set("stats:generate:" + key, String.valueOf(totalGenerate.get()));
redisTemplate.opsForValue().set("stats:verify:" + key, String.valueOf(totalVerify.get()));
redisTemplate.opsForValue().set("stats:success:" + key, String.valueOf(totalSuccess.get()));
redisTemplate.opsForValue().set("stats:fail:" + key, String.valueOf(totalFail.get()));
log.debug("[STATS] Synced to Redis: total={}", totalRequests.get());
} catch (Exception e) {
log.warn("[STATS] Failed to sync to Redis: {}", e.getMessage());
}
}
}
@@ -0,0 +1,52 @@
server:
port: 18200
spring:
datasource:
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:captcha_forge}
username: ${DB_USER:pgsql}
password: ${DB_PASS:}
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: none
show-sql: false
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
sql:
init:
mode: never
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASS:}
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: Asia/Shanghai
captcha:
prefix: captcha
expire:
default: 120000
init-default-resource: true
local-cache-enabled: true
local-cache-size: 20
jwt:
secret: ${JWT_SECRET:tianai-captcha-jwt-secret-key-must-be-at-least-256-bits-long-for-hs256}
expiration: ${JWT_EXPIRATION:86400000}
minio:
endpoint: ${MINIO_ENDPOINT:http://localhost:9000}
access-key: ${MINIO_ACCESS_KEY:minioadmin}
secret-key: ${MINIO_SECRET_KEY:minioadmin}
bucket: ${MINIO_BUCKET:captcha-resources}
logging:
level:
cloud.tianai.captcha: INFO
root: WARN
@@ -31,6 +31,16 @@ captcha:
local-cache-enabled: true
local-cache-size: 20
jwt:
secret: tianai-captcha-jwt-secret-key-must-be-at-least-256-bits-long-for-hs256
expiration: 86400000
minio:
endpoint: http://localhost:9000
access-key: minioadmin
secret-key: minioadmin
bucket: captcha-resources
logging:
level:
cloud.tianai.captcha: INFO
@@ -0,0 +1,74 @@
package cloud.tianai.captcha.platform;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.*;
import org.springframework.test.context.ActiveProfiles;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
class CaptchaApiIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
private String jwtToken;
@BeforeEach
void login() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, String>> request = new HttpEntity<>(
Map.of("username", "admin", "password", "admin"), headers);
var response = restTemplate.postForEntity("/api/auth/login", request, Map.class);
if (response.getBody() != null && response.getBody().get("data") != null) {
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) response.getBody().get("data");
if (data != null && data.get("token") != null) {
jwtToken = (String) data.get("token");
}
}
}
private HttpHeaders authHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
if (jwtToken != null) {
headers.setBearerAuth(jwtToken);
}
return headers;
}
@Test
void contextLoads() {
assertNotNull(restTemplate);
}
@Test
void loginReturnsToken() {
assertNotNull(jwtToken, "JWT token should not be null after login");
}
@Test
void statsEndpointReturnsData() {
HttpEntity<Void> entity = new HttpEntity<>(authHeaders());
var response = restTemplate.exchange("/api/admin/stats", HttpMethod.GET, entity, Map.class);
assertEquals(200, response.getStatusCode().value());
assertNotNull(response.getBody());
}
@Test
void siteListEndpointReturnsData() {
HttpEntity<Void> entity = new HttpEntity<>(authHeaders());
var response = restTemplate.exchange("/api/admin/sites?page=0&size=10", HttpMethod.GET, entity, Map.class);
assertEquals(200, response.getStatusCode().value());
assertNotNull(response.getBody());
}
}
@@ -0,0 +1,41 @@
spring:
datasource:
url: jdbc:h2:mem:testdb;MODE=PostgreSQL
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: validate
show-sql: false
properties:
hibernate:
dialect: org.hibernate.dialect.H2Dialect
h2:
console:
enabled: false
data:
redis:
host: localhost
port: 6379
jwt:
secret: test-secret-key-must-be-at-least-256-bits-long-for-hs256
expiration: 86400000
captcha:
prefix: captcha
expire:
default: 120000
init-default-resource: false
local-cache-enabled: false
minio:
endpoint: http://localhost:9000
access-key: minioadmin
secret-key: minioadmin
bucket: captcha-resources
logging:
level:
cloud.tianai.captcha: DEBUG
+2 -2
View File
@@ -111,9 +111,9 @@
'.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{opacity:0;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}}' +
'@keyframes pulse-ward{0%{transform:scale(1.0);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)}' +
+295 -71
View File
@@ -1,101 +1,325 @@
<!DOCTYPE html>
<html lang="en">
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<title>TianAI Captcha - 行为验证码演示</title>
<style>
#login-div {
width: 500px;
height: 500px;
border: 1px solid #ff5d39;
position: relative;
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
h1 {
text-align: center;
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
.input {
height: 50px;
.demo-container {
background: #fff;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
width: 100%;
border: 1px solid #ccc;
border-radius: 6px;
margin: 20px auto;
color: #ccc;
line-height: 50px;
text-align: left;
max-width: 480px;
}
.input-left {
border-right: 1px solid #ccc;
text-align: center;
width: 100px;
display: inline-block;
}
.login-btn {
/*margin: 0 auto;*/
display: inline-block;
width: 160px;
height: 50px;
background-color: #4BC065;
.demo-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
line-height: 50px;
padding: 30px;
text-align: center;
border-radius: 6px;
}
.demo-header h1 {
font-size: 24px;
font-weight: 600;
margin-bottom: 8px;
}
.demo-header p {
font-size: 14px;
opacity: 0.9;
}
.demo-body {
padding: 30px;
}
.captcha-type-selector {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-bottom: 24px;
}
.type-btn {
padding: 12px 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
background: #fff;
cursor: pointer;
transition: all 0.3s ease;
font-size: 13px;
font-weight: 500;
color: #666;
text-align: center;
}
.type-btn:hover {
border-color: #667eea;
color: #667eea;
background: #f8f9ff;
}
.type-btn.active {
border-color: #667eea;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
}
.captcha-wrapper {
position: relative;
min-height: 340px;
display: flex;
justify-content: center;
align-items: center;
background: #f8f9fa;
border-radius: 12px;
margin-bottom: 20px;
}
#captcha-box {
position: absolute;
left: 78px;
top: 83px;
position: relative;
}
.login-form {
margin-top: 20px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-size: 13px;
font-weight: 500;
color: #333;
margin-bottom: 6px;
}
.form-group input {
width: 100%;
padding: 12px 16px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.3s;
outline: none;
}
.form-group input:focus {
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.login-btn {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
border: none;
border-radius: 8px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.login-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
}
.login-btn:active {
transform: translateY(0);
}
.login-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.result-toast {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%) translateY(-100px);
padding: 16px 24px;
border-radius: 8px;
color: #fff;
font-size: 14px;
font-weight: 500;
z-index: 10000;
transition: transform 0.3s ease;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.result-toast.show {
transform: translateX(-50%) translateY(0);
}
.result-toast.success {
background: #10b981;
}
.result-toast.error {
background: #ef4444;
}
.demo-footer {
padding: 16px 30px;
background: #f8f9fa;
border-top: 1px solid #eee;
text-align: center;
font-size: 12px;
color: #999;
}
.demo-footer a {
color: #667eea;
text-decoration: none;
}
.demo-footer a:hover {
text-decoration: underline;
}
@media (max-width: 480px) {
.demo-container {
border-radius: 12px;
}
.demo-header {
padding: 24px 20px;
}
.demo-header h1 {
font-size: 20px;
}
.demo-body {
padding: 20px;
}
.captcha-type-selector {
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.type-btn {
padding: 10px 12px;
font-size: 12px;
}
}
</style>
</head>
<body>
<!-- 验证码存放的div块 -->
<div id="login-div">
<!-- 装载验证码的DIV -->
<div id="captcha-box"></div>
<h1>用户登录</h1>
<div class="input">
<span class="input-left">用户名</span>
xxxxx
<div class="demo-container">
<div class="demo-header">
<h1>TianAI Captcha</h1>
<p>行为验证码 SDK 演示</p>
</div>
<div class="demo-body">
<div class="captcha-type-selector">
<div class="type-btn active" data-type="SLIDER">滑块验证</div>
<div class="type-btn" data-type="ROTATE">旋转验证</div>
<div class="type-btn" data-type="CONCAT">滑动还原</div>
<div class="type-btn" data-type="WORD_IMAGE_CLICK">文字点选</div>
</div>
<div class="captcha-wrapper">
<div id="captcha-box"></div>
</div>
<div class="login-form">
<div class="form-group">
<label>用户名</label>
<input type="text" placeholder="请输入用户名">
</div>
<div class="form-group">
<label>密码</label>
<input type="password" placeholder="请输入密码">
</div>
<button class="login-btn">登录</button>
</div>
</div>
<div class="demo-footer">
Powered by <a href="https://github.com/dromara/tianai-captcha" target="_blank">TianAI Captcha</a>
</div>
</div>
<div class="input">
<span class="input-left">密码</span>
xxxxx
</div>
<div class="login-btn" data-type="ROTATE">登录(旋转)</div>
<div class="login-btn" data-type="CONCAT">登录(拼接)</div>
<div class="login-btn" data-type="WORD_IMAGE_CLICK">登录(汉字点选)</div>
<div class="login-btn" data-type="SLIDER">登录(滑块拼图)</div>
</div>
<script src="tac/js/tac.min.js"></script>
<link href="tac/css/tac.css" rel="stylesheet">
<script>
let currentType = 'SLIDER';
let captchaInstance = null;
function initCaptcha(type) {
currentType = type;
const box = document.getElementById('captcha-box');
box.innerHTML = '';
<script>
document.querySelectorAll(".login-btn").forEach(el => {
el.addEventListener("click", e => {
// 样式配置
const config = {
requestCaptchaDataUrl: "http://localhost:8080/gen?type=" + el.dataset.type,
requestCaptchaDataUrl: `http://localhost:8080/gen?type=${type}`,
validCaptchaUrl: "http://localhost:8080/check",
bindEl: "#captcha-box"
}
// const style = {
// logoUrl : null
//
// }
bindEl: "#captcha-box",
validSuccess: (res, c, tac) => {
alert('验证成功!');
},
validFail: (res, c, tac) => {
alert('验证失败,请重试');
tac.reloadCaptcha();
},
btnRefreshFun: (el, tac) => {
tac.reloadCaptcha();
},
btnCloseFun: (el, tac) => {
tac.destroyWindow();
}
};
const captcha = new TAC(config, null);
captcha.init();
})
})
const style = {
logoUrl: null
};
window.initTAC("./tac", config, style).then(tac => {
captchaInstance = tac;
tac.init();
}).catch(e => {
console.error("初始化TAC失败", e);
});
}
</script>
document.querySelectorAll('.type-btn').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('.type-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active');
initCaptcha(this.dataset.type);
});
});
initCaptcha('SLIDER');
</script>
</body>
</html>
@@ -0,0 +1,181 @@
import "./angle.scss"
import {Dom, CommonCaptcha, destroyEvent} from "../common/common.js"
/**
* 角度验证验证码 - 用户需要旋转图片到正确角度
*/
const TYPE = "ANGLE"
function getTemplate(styleConfig) {
return `
<div id="tianai-captcha" class="tianai-captcha-slider tianai-captcha-angle">
<div class="click-tip">
<span id="tianai-captcha-click-track-font" style="font-size: ${styleConfig.i18n?.angle_title_size || '14px'}">${styleConfig.i18n?.angle_title || '旋转图片到正确角度'}</span>
</div>
<div class="content">
<div class="rotate-container">
<div class="rotate-img" id="rotate-img">
<img id="tianai-captcha-rotate-bg-img" src="" alt/>
</div>
<div class="rotate-indicator" id="rotate-indicator">
<div class="indicator-line" id="indicator-line"></div>
</div>
</div>
<div class="slider-bar" id="tianai-captcha-slider-bar">
<div class="slider-bar-bg"></div>
<div class="slider-bar-track" id="tianai-captcha-slider-track"></div>
<div class="slider-bar-btn" id="tianai-captcha-slider-btn">
<div class="slider-btn-icon"></div>
</div>
</div>
<div class="tianai-captcha-tips" id="tianai-captcha-tips"></div>
</div>
</div>
`;
}
class Angle extends CommonCaptcha{
constructor(boxEl, styleConfig) {
super();
this.boxEl = boxEl;
this.styleConfig = styleConfig;
this.type = TYPE;
this.currentCaptchaData = {};
this.isDragging = false;
this.startX = 0;
this.currentAngle = 0;
}
init(captchaData, endCallback, loadSuccessCallback) {
// 重载样式
this.destroy();
this.boxEl.append(getTemplate(this.styleConfig));
this.el = this.boxEl.find("#tianai-captcha");
// 载入验证码
this.loadCaptchaForData(this, captchaData);
this.endCallback = endCallback;
if (loadSuccessCallback) {
// 加载成功
loadSuccessCallback(this);
}
return this;
}
bindDragEvents() {
const sliderBtn = document.getElementById('tianai-captcha-slider-btn');
const rotateImg = document.getElementById('rotate-img');
if (!sliderBtn || !rotateImg) return;
// 鼠标按下
sliderBtn.addEventListener('mousedown', (e) => {
this.isDragging = true;
this.startX = e.pageX;
this.currentCaptchaData.startTime = new Date();
e.preventDefault();
});
// 鼠标移动
document.addEventListener('mousemove', (e) => {
if (!this.isDragging) return;
const moveX = e.pageX - this.startX;
const sliderBar = document.getElementById('tianai-captcha-slider-bar');
const barWidth = sliderBar ? sliderBar.offsetWidth - 40 : 200;
const left = Math.max(0, Math.min(moveX, barWidth));
sliderBtn.style.left = left + 'px';
document.getElementById('tianai-captcha-slider-track').style.width = (left + 20) + 'px';
// 计算旋转角度
this.currentAngle = (left / barWidth) * 360;
rotateImg.style.transform = 'rotate(' + this.currentAngle + 'deg)';
// 更新指示器
this.updateIndicator();
// 记录轨迹
if (!this.currentCaptchaData.trackList) {
this.currentCaptchaData.trackList = [];
}
this.currentCaptchaData.trackList.push({
x: e.pageX,
y: e.pageY,
angle: this.currentAngle,
t: new Date().getTime() - this.currentCaptchaData.startTime.getTime()
});
});
// 鼠标松开
document.addEventListener('mouseup', () => {
if (!this.isDragging) return;
this.isDragging = false;
this.currentCaptchaData.stopTime = new Date();
this.currentCaptchaData.currentAngle = this.currentAngle;
this.endCallback(this.currentCaptchaData, this);
});
// 触摸事件
sliderBtn.addEventListener('touchstart', (e) => {
this.isDragging = true;
this.startX = e.touches[0].pageX;
this.currentCaptchaData.startTime = new Date();
e.preventDefault();
});
document.addEventListener('touchmove', (e) => {
if (!this.isDragging) return;
const moveX = e.touches[0].pageX - this.startX;
const sliderBar = document.getElementById('tianai-captcha-slider-bar');
const barWidth = sliderBar ? sliderBar.offsetWidth - 40 : 200;
const left = Math.max(0, Math.min(moveX, barWidth));
sliderBtn.style.left = left + 'px';
document.getElementById('tianai-captcha-slider-track').style.width = (left + 20) + 'px';
this.currentAngle = (left / barWidth) * 360;
rotateImg.style.transform = 'rotate(' + this.currentAngle + 'deg)';
this.updateIndicator();
});
document.addEventListener('touchend', () => {
if (!this.isDragging) return;
this.isDragging = false;
this.currentCaptchaData.stopTime = new Date();
this.currentCaptchaData.currentAngle = this.currentAngle;
this.endCallback(this.currentCaptchaData, this);
});
}
updateIndicator() {
const indicatorLine = document.getElementById('indicator-line');
if (indicatorLine) {
indicatorLine.style.transform = 'rotate(' + this.currentAngle + 'deg)';
}
}
destroy () {
const existsCaptchaEl = this.boxEl.children("#tianai-captcha");
if (existsCaptchaEl) {
existsCaptchaEl.remove();
}
destroyEvent();
}
loadCaptchaForData (that, data) {
const bgImg = that.el.find("#tianai-captcha-rotate-bg-img");
bgImg.on("load",() => {
that.currentCaptchaData = {
bgImageWidth: bgImg.width(),
bgImageHeight: bgImg.height(),
startTime: new Date(),
trackList: [],
targetAngle: data.data.targetAngle || 0,
currentAngle: 0
};
that.currentCaptchaData.currentCaptchaId = data.data.id;
// 绑定滑动事件
setTimeout(() => that.bindDragEvents(), 100);
})
bgImg.attr("src", data.data.backgroundImage);
}
}
export default Angle;
@@ -0,0 +1,107 @@
#tianai-captcha.tianai-captcha-angle {
box-sizing: border-box;
.click-tip {
position: relative;
height: 40px;
width: 100%;
display: flex;
align-items: center;
#tianai-captcha-click-track-font {
font-size: 14px;
color: #333;
}
}
.content {
.rotate-container {
position: relative;
width: 200px;
height: 200px;
margin: 0 auto;
border-radius: 50%;
overflow: hidden;
border: 3px solid #e8e8e8;
.rotate-img {
width: 100%;
height: 100%;
transition: transform 0.1s;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.rotate-indicator {
position: absolute;
top: 50%;
left: 50%;
width: 2px;
height: 50%;
background-color: transparent;
transform-origin: bottom center;
transform: translate(-50%, -100%);
.indicator-line {
width: 100%;
height: 100%;
background-color: #ff4444;
transform-origin: bottom center;
}
}
}
.slider-bar {
position: relative;
height: 40px;
margin-top: 3px;
.slider-bar-bg {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
border-radius: 4px;
background-color: #e8e8e8;
}
.slider-bar-track {
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-radius: 4px;
background-image: linear-gradient(90deg, #409eff, #66b1ff);
transition: width 0.1s;
}
.slider-bar-btn {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 40px;
border-radius: 4px;
background-image: linear-gradient(173deg, hsl(38.09deg 91% 57.89%) 0%, hsl(38.09deg 89.38% 71.74%) 100%);
cursor: move;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
.slider-btn-icon {
width: 0;
height: 0;
border-style: solid;
border-width: 6px 0 6px 8px;
border-color: transparent transparent transparent #fff;
}
}
}
}
}
+45 -4
View File
@@ -4,6 +4,14 @@ import Rotate from "./rotate/rotate";
import Concat from "./concat/concat";
import Disable from "./disable/disable";
import WordImageClick from "./word_image_click/word_image_click";
import IconClick from "./icon_click/icon_click";
import WordOrderClick from "./word_order_click/word_order_click";
import Scratch from "./scratch/scratch";
import Jigsaw from "./jigsaw/jigsaw";
import CurveSlider from "./curve_slider/curve_slider";
import Angle from "./angle/angle";
import CurveDraw from "./curve_draw/curve_draw";
import ProofOfWork from "./proof_of_work/proof_of_work";
import {CaptchaConfig, wrapConfig, wrapStyle} from "./config/config";
import {clearAllPreventDefault} from "./common/common";
const template =
@@ -11,13 +19,30 @@ const template =
<div id="tianai-captcha-parent">
<div id="tianai-captcha-bg-img"></div>
<div id="tianai-captcha-box">
<div id="tianai-captcha-loading" class="loading"></div>
<div id="tianai-captcha-loading" class="loading">
<div class="loading-spinner"></div>
<div class="loading-text">加载中...</div>
</div>
</div>
<!-- 底部 -->
<div class="slider-bottom">
<img class="logo" id="tianai-captcha-logo" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeCAMAAAAM7l6QAAAAMFBMVEVHcEz3tkX3tkX3tkX3tkX3tkX3tkX3tkX3tkX3tkX3tkX3tkX3tkX3tkX3tkX3tkVmTmjZAAAAD3RSTlMASbTm8wh12hOGoCNiyTV98jvOAAABB0lEQVR42nVT0aIFEQiMorD0/397Lc5a7J0n1UylgIniLRKyDcbBDudZH2DYCAabn3PmTrjeUX+7rJGWx0SqVpzReAfTtKU5fgVCNfxWjB69USUDGwoOiauHpZEpSr0tCx8ILb3Dm3WgBbAlifAJk6+Ww6wqEUmpmIorQVZ1JtqKnDMjkb7AgIpO/wMCaQbuBuEtsBUxhuD9daUaZnApiQB8NAKotMwirGGr6mbXpPnHLHDmy6oy3FgP+1j8IBdVklFc01xUJwv3NR0rIeXV5zpzdlruiijzNq/ufOeKWzZLP3160u5P8RjT1M+HHFtx+PwGyOZqT/D8ROOfjOInTLBIHjy/hvwHxkwPu5cCE1QAAAAASUVORK5CYII=" id="tianai-captcha-logo"></img>
<div class="close-btn" id="tianai-captcha-slider-close-btn"></div>
<div class="refresh-btn" id="tianai-captcha-slider-refresh-btn"></div>
<div class="bottom-left">
<img class="logo" id="tianai-captcha-logo" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 120 28'%3E%3Ctext x='0' y='22' font-family='Arial,sans-serif' font-size='18' font-weight='bold' fill='%23667eea'%3ETianAI%3C/text%3E%3C/svg%3E" id="tianai-captcha-logo">
</div>
<div class="bottom-right">
<div class="icon-btn refresh-btn" id="tianai-captcha-slider-refresh-btn" title="刷新">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<path d="M23 4v6h-6M1 20v-6h6"/>
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/>
</svg>
</div>
<div class="icon-btn close-btn" id="tianai-captcha-slider-close-btn" title="关闭">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</div>
</div>
</div>
</div>
`;
@@ -33,6 +58,22 @@ function createCaptchaByType(type, tac) {
return new Concat(box, styleConfig);
case "WORD_IMAGE_CLICK":
return new WordImageClick(box, styleConfig);
case "ICON_CLICK":
return new IconClick(box, styleConfig);
case "WORD_ORDER_CLICK":
return new WordOrderClick(box, styleConfig);
case "SCRATCH":
return new Scratch(box, styleConfig);
case "JIGSAW":
return new Jigsaw(box, styleConfig);
case "CURVE_SLIDER":
return new CurveSlider(box, styleConfig);
case "ANGLE":
return new Angle(box, styleConfig);
case "CURVE_DRAW":
return new CurveDraw(box, styleConfig);
case "PROOF_OF_WORK":
return new ProofOfWork(box, styleConfig);
case "DISABLED":
return new Disable(box, styleConfig);
default:
+85 -21
View File
@@ -1,39 +1,58 @@
#tianai-captcha-parent {
box-shadow: 0 0 11px 0 #999999;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
width: 318px;
height: 318px;
overflow: hidden;
position: relative;
z-index: 997;
box-sizing: border-box;
border-radius: 5px;
border-radius: 8px;
padding: 8px;
#tianai-captcha-box {
height: 260px;
width: 100%;
position: relative;
overflow: hidden;
border-radius: 6px;
.loading {
width: 120px;
height: 20px;
-webkit-mask: linear-gradient(90deg, #000 70%, #0000 0) 0/20%;
background: linear-gradient(#f7b645 0 0) 0 / 0% no-repeat #dddddd6b;
animation: cartoon 1s infinite steps(6);
margin: 120px auto;
@keyframes cartoon {
100% {
background-size: 120%;
}
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: #fafafa;
.loading-spinner {
width: 36px;
height: 36px;
border: 3px solid #f0f0f0;
border-top-color: #f7b645;
border-radius: 50%;
animation: tianai-spin 0.8s linear infinite;
}
.loading-text {
margin-top: 12px;
font-size: 12px;
color: #999;
}
}
@keyframes tianai-spin {
to { transform: rotate(360deg); }
}
#tianai-captcha {
transform-style: preserve-3d;
will-change: transform;
transition-duration: .45s;
//transition-timing-function: cubic-bezier(0.36, 0.3, 0.42, 1.5);
transition-duration: 0.45s;
transform: translateX(-300px);
}
}
#tianai-captcha-bg-img {
background-color: #fff;
background-position: top;
@@ -44,11 +63,54 @@
top: 0;
left: 0;
position: absolute;
border-radius: 6px;
//background-image: url("");
border-radius: 8px;
}
.slider-bottom {
.slider-bottom {
display: flex;
justify-content: space-between;
align-items: center;
height: 26px;
width: 100%;
padding: 0 2px;
.bottom-left {
display: flex;
align-items: center;
.logo {
height: 22px;
}
}
.bottom-right {
display: flex;
align-items: center;
gap: 4px;
.icon-btn {
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
cursor: pointer;
color: #999;
transition: all 0.2s;
&:hover {
background: #f0f0f0;
color: #666;
}
svg {
width: 14px;
height: 14px;
}
}
}
.close-btn {
width: 20px;
height: 20px;
@@ -59,6 +121,7 @@
margin-right: 2px;
cursor: pointer;
}
.refresh-btn {
width: 20px;
height: 20px;
@@ -69,13 +132,16 @@
margin-right: 10px;
cursor: pointer;
}
.logo {
height: 30px;
float: left;
}
height: 19px;
width: 100%;
}
.slider-move-shadow {
animation: myanimation 2s infinite;
height: 100%;
@@ -88,20 +154,18 @@
box-shadow: 1px 1px 1px #fff;
border-radius: 50%;
}
#tianai-captcha-slider-move-track-mask {
border-width: 1px;
border-style: solid;
border-color: #00f4ab;
width: 0;
height: 32px;
background-color: #a9ffe5;
opacity: .5;
opacity: 0.5;
position: absolute;
top: -1px;
left: -1px;
border-radius: 5px;
}
}
@@ -0,0 +1,195 @@
import "./curve_draw.scss"
import {Dom, CommonCaptcha, destroyEvent} from "../common/common.js"
/**
* 曲线绘制验证码 - 用户需要绘制指定曲线
*/
const TYPE = "CURVE_DRAW"
function getTemplate(styleConfig) {
return `
<div id="tianai-captcha" class="tianai-captcha-slider tianai-captcha-curve-draw">
<div class="click-tip">
<span id="tianai-captcha-click-track-font" style="font-size: ${styleConfig.i18n?.curve_draw_title_size || '14px'}">${styleConfig.i18n?.curve_draw_title || '请沿虚线绘制曲线'}</span>
</div>
<div class="content">
<div class="draw-container" id="draw-container">
<canvas id="tianai-captcha-draw-canvas" class="draw-canvas"></canvas>
</div>
<div class="draw-actions">
<button class="draw-btn reset-btn" id="draw-reset-btn">重绘</button>
<button class="draw-btn confirm-btn" id="draw-confirm-btn">确定</button>
</div>
<div class="tianai-captcha-tips" id="tianai-captcha-tips"></div>
</div>
</div>
`;
}
class CurveDraw extends CommonCaptcha{
constructor(boxEl, styleConfig) {
super();
this.boxEl = boxEl;
this.styleConfig = styleConfig;
this.type = TYPE;
this.currentCaptchaData = {};
this.isDrawing = false;
this.drawPoints = [];
}
init(captchaData, endCallback, loadSuccessCallback) {
// 重载样式
this.destroy();
this.boxEl.append(getTemplate(this.styleConfig));
this.el = this.boxEl.find("#tianai-captcha");
// 载入验证码
this.loadCaptchaForData(this, captchaData);
this.endCallback = endCallback;
if (loadSuccessCallback) {
// 加载成功
loadSuccessCallback(this);
}
return this;
}
initCanvas() {
const canvas = document.getElementById('tianai-captcha-draw-canvas');
const container = document.getElementById('draw-container');
if (!canvas || !container) return;
const ctx = canvas.getContext('2d');
const rect = container.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
// 绘制提示曲线(虚线)
this.drawGuideCurve(ctx, canvas.width, canvas.height);
// 绑定绘制事件
this.bindDrawEvents(canvas, ctx);
}
drawGuideCurve(ctx, width, height) {
const curveData = this.currentCaptchaData.curveData;
if (!curveData || curveData.length < 2) return;
ctx.beginPath();
ctx.strokeStyle = 'rgba(64, 158, 255, 0.5)';
ctx.lineWidth = 2;
ctx.setLineDash([5, 5]);
ctx.moveTo(curveData[0].x * width, curveData[0].y * height);
for (let i = 1; i < curveData.length; i++) {
ctx.lineTo(curveData[i].x * width, curveData[i].y * height);
}
ctx.stroke();
}
bindDrawEvents(canvas, ctx) {
const draw = (e) => {
if (!this.isDrawing) return;
const rect = canvas.getBoundingClientRect();
let x, y;
if (e.touches) {
x = e.touches[0].clientX - rect.left;
y = e.touches[0].clientY - rect.top;
} else {
x = e.offsetX;
y = e.offsetY;
}
this.drawPoints.push({x, y});
ctx.globalCompositeOperation = 'source-over';
ctx.strokeStyle = '#409eff';
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
if (this.drawPoints.length > 1) {
const prev = this.drawPoints[this.drawPoints.length - 2];
ctx.beginPath();
ctx.moveTo(prev.x, prev.y);
ctx.lineTo(x, y);
ctx.stroke();
}
};
// 鼠标事件
canvas.addEventListener('mousedown', (e) => {
this.isDrawing = true;
this.currentCaptchaData.startTime = new Date();
this.drawPoints = [];
ctx.clearRect(0, 0, canvas.width, canvas.height);
this.drawGuideCurve(ctx, canvas.width, canvas.height);
draw(e);
});
canvas.addEventListener('mousemove', draw);
canvas.addEventListener('mouseup', () => {
this.isDrawing = false;
});
canvas.addEventListener('mouseleave', () => {
this.isDrawing = false;
});
// 触摸事件
canvas.addEventListener('touchstart', (e) => {
this.isDrawing = true;
this.currentCaptchaData.startTime = new Date();
this.drawPoints = [];
ctx.clearRect(0, 0, canvas.width, canvas.height);
this.drawGuideCurve(ctx, canvas.width, canvas.height);
e.preventDefault();
draw(e);
});
canvas.addEventListener('touchmove', (e) => {
e.preventDefault();
draw(e);
});
canvas.addEventListener('touchend', () => {
this.isDrawing = false;
});
// 重绘按钮
const resetBtn = document.getElementById('draw-reset-btn');
if (resetBtn) {
resetBtn.addEventListener('click', () => {
this.drawPoints = [];
ctx.clearRect(0, 0, canvas.width, canvas.height);
this.drawGuideCurve(ctx, canvas.width, canvas.height);
});
}
// 确定按钮
const confirmBtn = document.getElementById('draw-confirm-btn');
if (confirmBtn) {
confirmBtn.addEventListener('click', () => {
if (this.drawPoints.length > 0) {
this.currentCaptchaData.stopTime = new Date();
this.currentCaptchaData.drawPoints = this.drawPoints;
this.endCallback(this.currentCaptchaData, this);
}
});
}
}
destroy () {
const existsCaptchaEl = this.boxEl.children("#tianai-captcha");
if (existsCaptchaEl) {
existsCaptchaEl.remove();
}
destroyEvent();
}
loadCaptchaForData (that, data) {
that.currentCaptchaData = {
startTime: new Date(),
drawPoints: [],
curveData: data.data.curveData || []
};
that.currentCaptchaData.currentCaptchaId = data.data.id;
// 初始化Canvas
setTimeout(() => that.initCanvas(), 100);
}
}
export default CurveDraw;
@@ -0,0 +1,64 @@
#tianai-captcha.tianai-captcha-curve-draw {
box-sizing: border-box;
.click-tip {
position: relative;
height: 40px;
width: 100%;
display: flex;
align-items: center;
#tianai-captcha-click-track-font {
font-size: 14px;
color: #333;
}
}
.content {
.draw-container {
width: 100%;
height: 180px;
position: relative;
overflow: hidden;
border-radius: 4px;
border: 1px solid #e8e8e8;
.draw-canvas {
width: 100%;
height: 100%;
cursor: crosshair;
touch-action: none;
}
}
.draw-actions {
display: flex;
gap: 10px;
margin-top: 8px;
.draw-btn {
flex: 1;
height: 32px;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: opacity 0.2s;
&:hover {
opacity: 0.9;
}
&.reset-btn {
background-color: #e8e8e8;
color: #666;
}
&.confirm-btn {
background-image: linear-gradient(173deg, hsl(38.09deg 91% 57.89%) 0%, hsl(38.09deg 89.38% 71.74%) 100%);
color: #fff;
}
}
}
}
}
@@ -0,0 +1,205 @@
import "./curve_slider.scss"
import {Dom, CommonCaptcha, destroyEvent} from "../common/common.js"
/**
* 曲线滑块验证码 - 用户需要沿曲线轨迹滑动
*/
const TYPE = "CURVE_SLIDER"
function getTemplate(styleConfig) {
return `
<div id="tianai-captcha" class="tianai-captcha-slider tianai-captcha-curve-slider">
<div class="click-tip">
<span id="tianai-captcha-click-track-font" style="font-size: ${styleConfig.i18n?.curve_slider_title_size || '14px'}">${styleConfig.i18n?.curve_slider_title || '沿曲线轨迹滑动到终点'}</span>
</div>
<div class="content">
<div class="bg-img-div">
<img id="tianai-captcha-slider-bg-img" src="" alt/>
<canvas id="tianai-captcha-curve-canvas" class="curve-canvas"></canvas>
<div class="curve-point" id="curve-point"></div>
<div class="curve-end-point" id="curve-end-point"></div>
</div>
<div class="slider-bar" id="tianai-captcha-slider-bar">
<div class="slider-bar-bg"></div>
<div class="slider-bar-track" id="tianai-captcha-slider-track"></div>
<div class="slider-bar-btn" id="tianai-captcha-slider-btn">
<div class="slider-btn-icon"></div>
</div>
</div>
<div class="tianai-captcha-tips" id="tianai-captcha-tips"></div>
</div>
</div>
`;
}
class CurveSlider extends CommonCaptcha{
constructor(boxEl, styleConfig) {
super();
this.boxEl = boxEl;
this.styleConfig = styleConfig;
this.type = TYPE;
this.currentCaptchaData = {};
this.isDragging = false;
this.startX = 0;
}
init(captchaData, endCallback, loadSuccessCallback) {
// 重载样式
this.destroy();
this.boxEl.append(getTemplate(this.styleConfig));
this.el = this.boxEl.find("#tianai-captcha");
// 载入验证码
this.loadCaptchaForData(this, captchaData);
this.endCallback = endCallback;
if (loadSuccessCallback) {
// 加载成功
loadSuccessCallback(this);
}
return this;
}
initCanvas() {
const canvas = document.getElementById('tianai-captcha-curve-canvas');
const bgImg = document.getElementById('tianai-captcha-slider-bg-img');
if (!canvas || !bgImg) return;
const ctx = canvas.getContext('2d');
canvas.width = bgImg.width;
canvas.height = bgImg.height;
// 绘制曲线
this.drawCurve(ctx, canvas.width, canvas.height);
// 绑定滑动事件
this.bindDragEvents(canvas);
}
drawCurve(ctx, width, height) {
const curveData = this.currentCaptchaData.curveData;
if (!curveData || curveData.length < 2) return;
ctx.beginPath();
ctx.strokeStyle = 'rgba(255, 165, 0, 0.8)';
ctx.lineWidth = 3;
ctx.setLineDash([5, 5]);
ctx.moveTo(curveData[0].x, curveData[0].y);
for (let i = 1; i < curveData.length; i++) {
ctx.lineTo(curveData[i].x, curveData[i].y);
}
ctx.stroke();
// 绘制起点
ctx.beginPath();
ctx.fillStyle = '#409eff';
ctx.arc(curveData[0].x, curveData[0].y, 8, 0, Math.PI * 2);
ctx.fill();
// 绘制终点
const endPoint = curveData[curveData.length - 1];
ctx.beginPath();
ctx.fillStyle = '#ff4444';
ctx.arc(endPoint.x, endPoint.y, 8, 0, Math.PI * 2);
ctx.fill();
}
bindDragEvents(canvas) {
const point = document.getElementById('curve-point');
const sliderBtn = document.getElementById('tianai-captcha-slider-btn');
if (!point || !sliderBtn) return;
// 鼠标按下
sliderBtn.addEventListener('mousedown', (e) => {
this.isDragging = true;
this.startX = e.pageX;
this.currentCaptchaData.startTime = new Date();
e.preventDefault();
});
// 鼠标移动
document.addEventListener('mousemove', (e) => {
if (!this.isDragging) return;
const moveX = e.pageX - this.startX;
const sliderBar = document.getElementById('tianai-captcha-slider-bar');
const barWidth = sliderBar ? sliderBar.offsetWidth - 40 : 200;
const left = Math.max(0, Math.min(moveX, barWidth));
sliderBtn.style.left = left + 'px';
document.getElementById('tianai-captcha-slider-track').style.width = (left + 20) + 'px';
// 记录轨迹
if (!this.currentCaptchaData.trackList) {
this.currentCaptchaData.trackList = [];
}
this.currentCaptchaData.trackList.push({
x: e.pageX,
y: e.pageY,
t: new Date().getTime() - this.currentCaptchaData.startTime.getTime()
});
});
// 鼠标松开
document.addEventListener('mouseup', () => {
if (!this.isDragging) return;
this.isDragging = false;
this.currentCaptchaData.stopTime = new Date();
const sliderBtn = document.getElementById('tianai-captcha-slider-btn');
this.currentCaptchaData.left = sliderBtn ? sliderBtn.offsetLeft : 0;
this.endCallback(this.currentCaptchaData, this);
});
// 触摸事件
sliderBtn.addEventListener('touchstart', (e) => {
this.isDragging = true;
this.startX = e.touches[0].pageX;
this.currentCaptchaData.startTime = new Date();
e.preventDefault();
});
document.addEventListener('touchmove', (e) => {
if (!this.isDragging) return;
const moveX = e.touches[0].pageX - this.startX;
const sliderBar = document.getElementById('tianai-captcha-slider-bar');
const barWidth = sliderBar ? sliderBar.offsetWidth - 40 : 200;
const left = Math.max(0, Math.min(moveX, barWidth));
sliderBtn.style.left = left + 'px';
document.getElementById('tianai-captcha-slider-track').style.width = (left + 20) + 'px';
});
document.addEventListener('touchend', () => {
if (!this.isDragging) return;
this.isDragging = false;
this.currentCaptchaData.stopTime = new Date();
const sliderBtn = document.getElementById('tianai-captcha-slider-btn');
this.currentCaptchaData.left = sliderBtn ? sliderBtn.offsetLeft : 0;
this.endCallback(this.currentCaptchaData, this);
});
}
destroy () {
const existsCaptchaEl = this.boxEl.children("#tianai-captcha");
if (existsCaptchaEl) {
existsCaptchaEl.remove();
}
destroyEvent();
}
loadCaptchaForData (that, data) {
const bgImg = that.el.find("#tianai-captcha-slider-bg-img");
bgImg.on("load",() => {
that.currentCaptchaData = {
bgImageWidth: bgImg.width(),
bgImageHeight: bgImg.height(),
startTime: new Date(),
trackList: [],
curveData: data.data.curveData || []
};
that.currentCaptchaData.currentCaptchaId = data.data.id;
// 初始化Canvas
setTimeout(() => that.initCanvas(), 100);
})
bgImg.attr("src", data.data.backgroundImage);
}
}
export default CurveSlider;
@@ -0,0 +1,114 @@
#tianai-captcha.tianai-captcha-curve-slider {
box-sizing: border-box;
.click-tip {
position: relative;
height: 40px;
width: 100%;
display: flex;
align-items: center;
#tianai-captcha-click-track-font {
font-size: 14px;
color: #333;
}
}
.content {
.bg-img-div {
position: relative;
height: 180px;
overflow: hidden;
border-radius: 4px;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.curve-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.curve-point {
position: absolute;
width: 16px;
height: 16px;
border-radius: 50%;
background-color: #409eff;
border: 2px solid #fff;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
transform: translate(-50%, -50%);
display: none;
}
.curve-end-point {
position: absolute;
width: 16px;
height: 16px;
border-radius: 50%;
background-color: #ff4444;
border: 2px solid #fff;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
transform: translate(-50%, -50%);
display: none;
}
}
.slider-bar {
position: relative;
height: 40px;
margin-top: 3px;
.slider-bar-bg {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
border-radius: 4px;
background-color: #e8e8e8;
}
.slider-bar-track {
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-radius: 4px;
background-image: linear-gradient(90deg, #409eff, #66b1ff);
transition: width 0.1s;
}
.slider-bar-btn {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 40px;
border-radius: 4px;
background-image: linear-gradient(173deg, hsl(38.09deg 91% 57.89%) 0%, hsl(38.09deg 89.38% 71.74%) 100%);
cursor: move;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
transition: transform 0.1s;
.slider-btn-icon {
width: 0;
height: 0;
border-style: solid;
border-width: 6px 0 6px 8px;
border-color: transparent transparent transparent #fff;
}
}
}
}
}
@@ -0,0 +1,104 @@
import "./icon_click.scss"
import {Dom, CommonCaptcha, move, initConfig, destroyEvent} from "../common/common.js"
/**
* 图标点选验证码
*/
const TYPE = "ICON_CLICK"
function getTemplate(styleConfig) {
return `
<div id="tianai-captcha" class="tianai-captcha-slider tianai-captcha-icon-click">
<div class="click-tip">
<span id="tianai-captcha-click-track-font" style="font-size: ${styleConfig.i18n?.icon_click_title_size || '14px'}">${styleConfig.i18n?.icon_click_title || '请依次点击图中的图标'}</span>
<img src="" id="tianai-captcha-tip-img" class="tip-img">
</div>
<div class="content">
<div class="bg-img-div">
<img id="tianai-captcha-slider-bg-img" src="" alt/>
<canvas id="tianai-captcha-slider-bg-canvas"></canvas>
<div id="bg-img-click-mask"></div>
</div>
<div class="tianai-captcha-tips" id="tianai-captcha-tips"></div>
</div>
<div class="click-confirm-btn">确定</div>
</div>
`;
}
class IconClick extends CommonCaptcha{
constructor(boxEl, styleConfig) {
super();
this.boxEl = boxEl;
this.styleConfig = styleConfig;
this.type = TYPE;
this.currentCaptchaData = {}
}
init(captchaData, endCallback, loadSuccessCallback) {
// 重载样式
this.destroy();
this.boxEl.append(getTemplate(this.styleConfig));
this.el = this.boxEl.find("#tianai-captcha");
// 载入验证码
this.loadCaptchaForData(this, captchaData);
this.endCallback = endCallback;
const moveFun = move.bind(null, this);
// 绑定事件
this.el.find("#bg-img-click-mask").click((event) => {
if(event.target.className === "click-span") {
return;
}
this.currentCaptchaData.clickCount++;
const trackList = this.currentCaptchaData.trackList;
if (this.currentCaptchaData.clickCount === 1) {
this.currentCaptchaData.startTime = new Date();
// move 轨迹
window.addEventListener("mousemove", moveFun);
this.currentCaptchaData.startX = event.offsetX;
this.currentCaptchaData.startY = event.offsetY;
}
const startTime = this.currentCaptchaData.startTime;
trackList.push({
x: Math.round(event.offsetX),
y: Math.round(event.offsetY),
type: "click",
t: (new Date().getTime() - startTime.getTime())
});
const left = event.offsetX - 12;
const top = event.offsetY - 12;
this.el.find("#bg-img-click-mask").append("<span class='click-span' style='left:" + left + "px;top: " + top + "px'>" + this.currentCaptchaData.clickCount + "</span>")
});
this.el.find(".click-confirm-btn").click(() => {
if (this.currentCaptchaData.clickCount > 0) {
// 校验
this.currentCaptchaData.stopTime = new Date();
window.removeEventListener("mousemove", moveFun);
this.endCallback(this.currentCaptchaData,this);
}
});
if (loadSuccessCallback) {
// 加载成功
loadSuccessCallback(this);
}
return this;
}
destroy () {
const existsCaptchaEl = this.boxEl.children("#tianai-captcha");
if (existsCaptchaEl) {
existsCaptchaEl.remove();
}
destroyEvent();
}
loadCaptchaForData (that, data) {
const bgImg = that.el.find("#tianai-captcha-slider-bg-img");
const tipImg = that.el.find("#tianai-captcha-tip-img");
bgImg.on("load",() => {
that.currentCaptchaData = initConfig(bgImg.width(), bgImg.height(), tipImg.width(), tipImg.height());
that.currentCaptchaData.currentCaptchaId = data.data.id;
})
bgImg.attr("src", data.data.backgroundImage);
tipImg.attr("src", data.data.templateImage);
}
}
export default IconClick;
@@ -0,0 +1,75 @@
#tianai-captcha.tianai-captcha-icon-click {
box-sizing: border-box;
.click-tip {
position: relative;
height: 40px;
width: 100%;
.tip-img {
height: 35px;
position: absolute;
right: 15px;
}
#tianai-captcha-click-track-font {
font-size: 14px;
display: inline-block;
height: 40px;
line-height: 40px;
position: absolute;
color: #333;
}
}
.slider-bottom {
position: relative;
top: 6px;
}
.content {
#bg-img-click-mask {
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
cursor: pointer;
.click-span {
position: absolute;
left: 0;
top: 0;
border-radius: 50%;
background-color: #409eff;
width: 24px;
height: 24px;
text-align: center;
line-height: 24px;
color: #fff;
border: 2px solid #fff;
box-sizing: content-box;
font-size: 12px;
font-weight: bold;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
}
}
.click-confirm-btn {
width: 100%;
height: 35px;
border-radius: 4px;
background-image: linear-gradient(173deg, hsl(38.09deg 91% 57.89%) 0%, hsl(38.09deg 89.38% 71.74%) 100%);
font-size: 15px;
text-align: center;
box-sizing: border-box;
line-height: 35px;
color: #fff;
margin-top: 3px;
cursor: pointer;
transition: opacity 0.2s;
&:hover {
opacity: 0.9;
}
}
}
@@ -0,0 +1,111 @@
import "./jigsaw.scss"
import {Dom, CommonCaptcha, move, initConfig, destroyEvent} from "../common/common.js"
/**
* 乱序拼图验证码 - 用户需要拖动拼图块到正确位置
*/
const TYPE = "JIGSAW"
function getTemplate(styleConfig) {
return `
<div id="tianai-captcha" class="tianai-captcha-slider tianai-captcha-jigsaw">
<div class="click-tip">
<span id="tianai-captcha-click-track-font" style="font-size: ${styleConfig.i18n?.jigsaw_title_size || '14px'}">${styleConfig.i18n?.jigsaw_title || '拖动拼图块到正确位置'}</span>
</div>
<div class="content">
<div class="bg-img-div">
<img id="tianai-captcha-slider-bg-img" src="" alt/>
<div class="jigsaw-block" id="jigsaw-block"></div>
</div>
<div class="slider-bar" id="tianai-captcha-slider-bar">
<div class="slider-bar-bg"></div>
<div class="slider-bar-track" id="tianai-captcha-slider-track">
</div>
<div class="slider-bar-btn" id="tianai-captcha-slider-btn">
<div class="slider-btn-icon">
<div class="click-mask-btn" id="tianai-captcha-click-mask-btn"></div>
</div>
</div>
</div>
<div class="tianai-captcha-tips" id="tianai-captcha-tips"></div>
</div>
</div>
`;
}
class Jigsaw extends CommonCaptcha{
constructor(boxEl, styleConfig) {
super();
this.boxEl = boxEl;
this.styleConfig = styleConfig;
this.type = TYPE;
this.currentCaptchaData = {};
}
init(captchaData, endCallback, loadSuccessCallback) {
// 重载样式
this.destroy();
this.boxEl.append(getTemplate(this.styleConfig));
this.el = this.boxEl.find("#tianai-captcha");
// 载入验证码
this.loadCaptchaForData(this, captchaData);
this.endCallback = endCallback;
const moveFun = move.bind(null, this);
// 绑定事件
this.el.find("#tianai-captcha-click-mask-btn").mousedown((event) => {
this.currentCaptchaData.moveFlag = true;
this.currentCaptchaData.startTime = new Date();
// move 轨迹
window.addEventListener("mousemove", moveFun);
this.currentCaptchaData.startX = event.pageX;
this.currentCaptchaData.startY = event.pageY;
this.el.find("#tianai-captcha-click-mask-btn").css("transition", "none");
});
window.addEventListener("mouseup", (event) => {
if (!this.currentCaptchaData.moveFlag) {
return;
}
this.currentCaptchaData.moveFlag = false;
this.currentCaptchaData.stopTime = new Date();
window.removeEventListener("mousemove", moveFun);
const left = this.el.find("#tianai-captcha-slider-btn").css("left") ? this.el.find("#tianai-captcha-slider-btn").css("left").split("px")[0] : 0;
this.currentCaptchaData.left = left;
this.endCallback(this.currentCaptchaData,this);
});
if (loadSuccessCallback) {
// 加载成功
loadSuccessCallback(this);
}
return this;
}
destroy () {
const existsCaptchaEl = this.boxEl.children("#tianai-captcha");
if (existsCaptchaEl) {
existsCaptchaEl.remove();
}
destroyEvent();
}
loadCaptchaForData (that, data) {
const bgImg = that.el.find("#tianai-captcha-slider-bg-img");
bgImg.on("load",() => {
that.currentCaptchaData = initConfig(bgImg.width(), bgImg.height(), data.data.blockSize, data.data.blockSize);
that.currentCaptchaData.currentCaptchaId = data.data.id;
// 设置拼图块位置
that.el.find("#jigsaw-block").css({
"width": data.data.blockSize + "px",
"height": data.data.blockSize + "px",
"left": data.data.blockPositionX + "px",
"top": data.data.blockPositionY + "px",
"background-image": "url(" + data.data.blockImage + ")",
"background-size": bgImg.width() + "px " + bgImg.height() + "px",
"background-position": "-" + data.data.blockPositionX + "px -" + data.data.blockPositionY + "px"
});
that.el.find("#tianai-captcha-slider-track").css("height", that.el.find("#tianai-captcha-slider-bar").outerHeight() + "px");
})
bgImg.attr("src", data.data.backgroundImage);
}
}
export default Jigsaw;
@@ -0,0 +1,114 @@
#tianai-captcha.tianai-captcha-jigsaw {
box-sizing: border-box;
.click-tip {
position: relative;
height: 40px;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
#tianai-captcha-click-track-font {
font-size: 14px;
color: #333;
}
}
.content {
position: relative;
.bg-img-div {
position: relative;
height: 180px;
overflow: hidden;
border-radius: 4px;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.jigsaw-block {
position: absolute;
cursor: move;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
border-radius: 4px;
transition: box-shadow 0.2s;
&:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
}
}
}
.slider-bar {
position: relative;
height: 40px;
margin-top: 3px;
.slider-bar-bg {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
border-radius: 4px;
background-color: #e8e8e8;
}
.slider-bar-track {
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-radius: 4px;
background-image: linear-gradient(90deg, #409eff, #66b1ff);
transition: width 0.1s;
}
.slider-bar-btn {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 40px;
border-radius: 4px;
background-image: linear-gradient(173deg, hsl(38.09deg 91% 57.89%) 0%, hsl(38.09deg 89.38% 71.74%) 100%);
cursor: move;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
transition: transform 0.1s;
.slider-btn-icon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
&::after {
content: '';
display: block;
width: 0;
height: 0;
border-style: solid;
border-width: 6px 0 6px 8px;
border-color: transparent transparent transparent #fff;
}
}
.click-mask-btn {
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
}
}
}
}
}
@@ -0,0 +1,196 @@
import "./proof_of_work.scss"
import {Dom, CommonCaptcha, destroyEvent} from "../common/common.js"
/**
* 工作量证明验证码 - 用户需要完成计算工作
*/
const TYPE = "PROOF_OF_WORK"
function getTemplate(styleConfig) {
return `
<div id="tianai-captcha" class="tianai-captcha-slider tianai-captcha-proof-of-work">
<div class="click-tip">
<span id="tianai-captcha-click-track-font" style="font-size: ${styleConfig.i18n?.proof_of_work_title_size || '14px'}">${styleConfig.i18n?.proof_of_work_title || '请完成工作量证明'}</span>
</div>
<div class="content">
<div class="pow-container">
<div class="pow-info">
<div class="pow-challenge" id="pow-challenge">
<span class="label">挑战:</span>
<span class="value" id="pow-challenge-value">-</span>
</div>
<div class="pow-difficulty" id="pow-difficulty">
<span class="label">难度:</span>
<span class="value" id="pow-difficulty-value">-</span>
</div>
</div>
<div class="pow-progress">
<div class="progress-bar" id="pow-progress-bar"></div>
<span class="progress-text" id="pow-progress-text">0%</span>
</div>
<div class="pow-status" id="pow-status">等待开始...</div>
</div>
<div class="pow-actions">
<button class="pow-btn start-btn" id="pow-start-btn">开始计算</button>
</div>
<div class="tianai-captcha-tips" id="tianai-captcha-tips"></div>
</div>
</div>
`;
}
class ProofOfWork extends CommonCaptcha{
constructor(boxEl, styleConfig) {
super();
this.boxEl = boxEl;
this.styleConfig = styleConfig;
this.type = TYPE;
this.currentCaptchaData = {};
this.isComputing = false;
this.worker = null;
}
init(captchaData, endCallback, loadSuccessCallback) {
// 重载样式
this.destroy();
this.boxEl.append(getTemplate(this.styleConfig));
this.el = this.boxEl.find("#tianai-captcha");
// 载入验证码
this.loadCaptchaForData(this, captchaData);
this.endCallback = endCallback;
if (loadSuccessCallback) {
// 加载成功
loadSuccessCallback(this);
}
return this;
}
bindEvents() {
const startBtn = document.getElementById('pow-start-btn');
if (!startBtn) return;
startBtn.addEventListener('click', () => {
if (this.isComputing) return;
this.startComputation();
});
}
startComputation() {
this.isComputing = true;
this.currentCaptchaData.startTime = new Date();
const startBtn = document.getElementById('pow-start-btn');
if (startBtn) {
startBtn.disabled = true;
startBtn.textContent = '计算中...';
}
// 更新状态
this.updateStatus('正在计算工作量证明...');
// 模拟计算过程(实际应用中应使用Web Worker)
this.simulateComputation();
}
simulateComputation() {
const challenge = this.currentCaptchaData.challenge || 'demo';
const difficulty = this.currentCaptchaData.difficulty || 4;
let nonce = 0;
const target = '0'.repeat(difficulty);
const compute = () => {
if (!this.isComputing) return;
// 简单的SHA-256模拟(实际应用中应使用真实的SHA-256)
const hash = this.simpleHash(challenge + nonce);
// 更新进度
const progress = Math.min(99, (nonce / 10000) * 100);
this.updateProgress(progress);
if (hash.startsWith(target)) {
// 找到解决方案
this.currentCaptchaData.nonce = nonce;
this.currentCaptchaData.hash = hash;
this.currentCaptchaData.stopTime = new Date();
this.updateProgress(100);
this.updateStatus('计算完成!');
this.endCallback(this.currentCaptchaData, this);
} else {
nonce++;
if (nonce % 100 === 0) {
// 每100次更新一次UI
setTimeout(compute, 0);
} else {
compute();
}
}
};
compute();
}
simpleHash(str) {
// 简单的哈希函数(仅用于演示)
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
// 转换为16进制字符串
return Math.abs(hash).toString(16).padStart(8, '0');
}
updateProgress(percent) {
const progressBar = document.getElementById('pow-progress-bar');
const progressText = document.getElementById('pow-progress-text');
if (progressBar) {
progressBar.style.width = percent + '%';
}
if (progressText) {
progressText.textContent = Math.round(percent) + '%';
}
}
updateStatus(text) {
const statusEl = document.getElementById('pow-status');
if (statusEl) {
statusEl.textContent = text;
}
}
destroy () {
this.isComputing = false;
const existsCaptchaEl = this.boxEl.children("#tianai-captcha");
if (existsCaptchaEl) {
existsCaptchaEl.remove();
}
destroyEvent();
}
loadCaptchaForData (that, data) {
that.currentCaptchaData = {
startTime: new Date(),
challenge: data.data.challenge || 'demo_challenge',
difficulty: data.data.difficulty || 4,
nonce: 0,
hash: ''
};
that.currentCaptchaData.currentCaptchaId = data.data.id;
// 更新UI
const challengeValue = document.getElementById('pow-challenge-value');
const difficultyValue = document.getElementById('pow-difficulty-value');
if (challengeValue) {
challengeValue.textContent = that.currentCaptchaData.challenge.substring(0, 20) + '...';
}
if (difficultyValue) {
difficultyValue.textContent = that.currentCaptchaData.difficulty;
}
// 绑定事件
setTimeout(() => that.bindEvents(), 100);
}
}
export default ProofOfWork;
@@ -0,0 +1,104 @@
#tianai-captcha.tianai-captcha-proof-of-work {
box-sizing: border-box;
.click-tip {
position: relative;
height: 40px;
width: 100%;
display: flex;
align-items: center;
#tianai-captcha-click-track-font {
font-size: 14px;
color: #333;
}
}
.content {
.pow-container {
background-color: #f5f5f5;
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
.pow-info {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
.pow-challenge,
.pow-difficulty {
font-size: 12px;
color: #666;
.label {
margin-right: 4px;
}
.value {
color: #333;
font-family: monospace;
}
}
}
.pow-progress {
position: relative;
height: 20px;
background-color: #e8e8e8;
border-radius: 10px;
overflow: hidden;
margin-bottom: 8px;
.progress-bar {
height: 100%;
background-image: linear-gradient(90deg, #409eff, #66b1ff);
transition: width 0.3s;
border-radius: 10px;
}
.progress-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 11px;
color: #333;
font-weight: bold;
}
}
.pow-status {
font-size: 12px;
color: #666;
text-align: center;
}
}
.pow-actions {
.pow-btn {
width: 100%;
height: 35px;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: opacity 0.2s;
&:hover {
opacity: 0.9;
}
&.start-btn {
background-image: linear-gradient(173deg, hsl(38.09deg 91% 57.89%) 0%, hsl(38.09deg 89.38% 71.74%) 100%);
color: #fff;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
}
}
}
@@ -0,0 +1,181 @@
import "./scratch.scss"
import {Dom, CommonCaptcha, destroyEvent} from "../common/common.js"
/**
* 刮刮乐验证码 - 用户需要刮开涂层
*/
const TYPE = "SCRATCH"
function getTemplate(styleConfig) {
return `
<div id="tianai-captcha" class="tianai-captcha-slider tianai-captcha-scratch">
<div class="scratch-tip">
<span id="tianai-captcha-scratch-track-font" style="font-size: ${styleConfig.i18n?.scratch_title_size || '14px'}">${styleConfig.i18n?.scratch_title || '请刮开涂层完成验证'}</span>
<span class="scratch-progress" id="scratch-progress">0%</span>
</div>
<div class="content">
<div class="scratch-container" id="scratch-container">
<img id="tianai-captcha-scratch-bg-img" src="" alt class="scratch-bg-img"/>
<canvas id="tianai-captcha-scratch-canvas" class="scratch-canvas"></canvas>
</div>
<div class="tianai-captcha-tips" id="tianai-captcha-tips"></div>
</div>
<div class="click-confirm-btn">确定</div>
</div>
`;
}
class Scratch extends CommonCaptcha{
constructor(boxEl, styleConfig) {
super();
this.boxEl = boxEl;
this.styleConfig = styleConfig;
this.type = TYPE;
this.currentCaptchaData = {};
this.isDrawing = false;
this.scratchPercentage = 0;
}
init(captchaData, endCallback, loadSuccessCallback) {
// 重载样式
this.destroy();
this.boxEl.append(getTemplate(this.styleConfig));
this.el = this.boxEl.find("#tianai-captcha");
// 载入验证码
this.loadCaptchaForData(this, captchaData);
this.endCallback = endCallback;
if (loadSuccessCallback) {
// 加载成功
loadSuccessCallback(this);
}
return this;
}
initCanvas() {
const canvas = document.getElementById('tianai-captcha-scratch-canvas');
const container = document.getElementById('scratch-container');
if (!canvas || !container) return;
const ctx = canvas.getContext('2d');
const rect = container.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
// 绘制灰色遮罩层
ctx.fillStyle = '#c0c0c0';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 绘制提示文字
ctx.fillStyle = '#999';
ctx.font = '14px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('请用鼠标刮开此区域', canvas.width / 2, canvas.height / 2);
// 绑定刮擦事件
this.bindScratchEvents(canvas, ctx);
}
bindScratchEvents(canvas, ctx) {
const scratch = (e) => {
if (!this.isDrawing) return;
const rect = canvas.getBoundingClientRect();
let x, y;
if (e.touches) {
x = e.touches[0].clientX - rect.left;
y = e.touches[0].clientY - rect.top;
} else {
x = e.offsetX;
y = e.offsetY;
}
ctx.globalCompositeOperation = 'destination-out';
ctx.beginPath();
ctx.arc(x, y, 15, 0, Math.PI * 2);
ctx.fill();
this.calculateScratchPercentage(canvas, ctx);
};
// 鼠标事件
canvas.addEventListener('mousedown', (e) => {
this.isDrawing = true;
this.currentCaptchaData.startTime = new Date();
scratch(e);
});
canvas.addEventListener('mousemove', scratch);
canvas.addEventListener('mouseup', () => {
this.isDrawing = false;
});
canvas.addEventListener('mouseleave', () => {
this.isDrawing = false;
});
// 触摸事件
canvas.addEventListener('touchstart', (e) => {
this.isDrawing = true;
this.currentCaptchaData.startTime = new Date();
e.preventDefault();
scratch(e);
});
canvas.addEventListener('touchmove', (e) => {
e.preventDefault();
scratch(e);
});
canvas.addEventListener('touchend', () => {
this.isDrawing = false;
});
}
calculateScratchPercentage(canvas, ctx) {
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const pixels = imageData.data;
let transparent = 0;
const total = pixels.length / 4;
for (let i = 3; i < pixels.length; i += 4) {
if (pixels[i] === 0) {
transparent++;
}
}
this.scratchPercentage = Math.round((transparent / total) * 100);
const progressEl = this.el.find("#scratch-progress");
progressEl.text(this.scratchPercentage + '%');
// 超过60%自动完成
if (this.scratchPercentage >= 60 && !this.currentCaptchaData.verified) {
this.currentCaptchaData.verified = true;
this.currentCaptchaData.stopTime = new Date();
this.currentCaptchaData.scratchPercentage = this.scratchPercentage;
this.endCallback(this.currentCaptchaData, this);
}
}
destroy () {
const existsCaptchaEl = this.boxEl.children("#tianai-captcha");
if (existsCaptchaEl) {
existsCaptchaEl.remove();
}
destroyEvent();
}
loadCaptchaForData (that, data) {
const bgImg = that.el.find("#tianai-captcha-scratch-bg-img");
bgImg.on("load",() => {
that.currentCaptchaData = {
bgImageWidth: bgImg.width(),
bgImageHeight: bgImg.height(),
startTime: new Date(),
trackList: [],
verified: false,
scratchPercentage: 0
};
that.currentCaptchaData.currentCaptchaId = data.data.id;
// 初始化Canvas
setTimeout(() => that.initCanvas(), 100);
})
bgImg.attr("src", data.data.backgroundImage);
}
}
export default Scratch;
@@ -0,0 +1,73 @@
#tianai-captcha.tianai-captcha-scratch {
box-sizing: border-box;
.scratch-tip {
position: relative;
height: 40px;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
#tianai-captcha-scratch-track-font {
font-size: 14px;
color: #333;
}
.scratch-progress {
font-size: 13px;
color: #409eff;
font-weight: bold;
}
}
.slider-bottom {
position: relative;
top: 6px;
}
.content {
.scratch-container {
width: 100%;
height: 180px;
position: relative;
overflow: hidden;
border-radius: 4px;
.scratch-bg-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.scratch-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
cursor: pointer;
touch-action: none;
}
}
}
.click-confirm-btn {
width: 100%;
height: 35px;
border-radius: 4px;
background-image: linear-gradient(173deg, hsl(38.09deg 91% 57.89%) 0%, hsl(38.09deg 89.38% 71.74%) 100%);
font-size: 15px;
text-align: center;
box-sizing: border-box;
line-height: 35px;
color: #fff;
margin-top: 3px;
cursor: pointer;
transition: opacity 0.2s;
&:hover {
opacity: 0.9;
}
}
}
@@ -0,0 +1,133 @@
import "./word_order_click.scss"
import {Dom, CommonCaptcha, move, initConfig, destroyEvent} from "../common/common.js"
/**
* 语序点选验证码 - 用户需要按顺序点击文字
*/
const TYPE = "WORD_ORDER_CLICK"
function getTemplate(styleConfig) {
return `
<div id="tianai-captcha" class="tianai-captcha-slider tianai-captcha-word-order-click">
<div class="click-tip">
<span id="tianai-captcha-click-track-font" style="font-size: ${styleConfig.i18n?.word_order_click_title_size || '14px'}">${styleConfig.i18n?.word_order_click_title || '请按顺序点击文字'}</span>
<div class="word-order-hint" id="word-order-hint"></div>
</div>
<div class="content">
<div class="bg-img-div">
<img id="tianai-captcha-slider-bg-img" src="" alt/>
<canvas id="tianai-captcha-slider-bg-canvas"></canvas>
<div id="bg-img-click-mask"></div>
</div>
<div class="tianai-captcha-tips" id="tianai-captcha-tips"></div>
</div>
<div class="click-confirm-btn">确定</div>
</div>
`;
}
class WordOrderClick extends CommonCaptcha{
constructor(boxEl, styleConfig) {
super();
this.boxEl = boxEl;
this.styleConfig = styleConfig;
this.type = TYPE;
this.currentCaptchaData = {}
}
init(captchaData, endCallback, loadSuccessCallback) {
// 重载样式
this.destroy();
this.boxEl.append(getTemplate(this.styleConfig));
this.el = this.boxEl.find("#tianai-captcha");
// 载入验证码
this.loadCaptchaForData(this, captchaData);
this.endCallback = endCallback;
const moveFun = move.bind(null, this);
// 初始化点击顺序提示
this.updateOrderHint();
// 绑定事件
this.el.find("#bg-img-click-mask").click((event) => {
if(event.target.className === "click-span") {
return;
}
this.currentCaptchaData.clickCount++;
const trackList = this.currentCaptchaData.trackList;
if (this.currentCaptchaData.clickCount === 1) {
this.currentCaptchaData.startTime = new Date();
// move 轨迹
window.addEventListener("mousemove", moveFun);
this.currentCaptchaData.startX = event.offsetX;
this.currentCaptchaData.startY = event.offsetY;
}
const startTime = this.currentCaptchaData.startTime;
trackList.push({
x: Math.round(event.offsetX),
y: Math.round(event.offsetY),
type: "click",
t: (new Date().getTime() - startTime.getTime())
});
const left = event.offsetX - 12;
const top = event.offsetY - 12;
this.el.find("#bg-img-click-mask").append("<span class='click-span' style='left:" + left + "px;top: " + top + "px'>" + this.currentCaptchaData.clickCount + "</span>")
this.updateOrderHint();
});
// 重新开始按钮
this.el.find(".click-reset-btn").click(() => {
this.currentCaptchaData.clickCount = 0;
this.currentCaptchaData.trackList = [];
this.el.find("#bg-img-click-mask").find(".click-span").remove();
this.updateOrderHint();
});
this.el.find(".click-confirm-btn").click(() => {
if (this.currentCaptchaData.clickCount > 0) {
// 校验
this.currentCaptchaData.stopTime = new Date();
window.removeEventListener("mousemove", moveFun);
this.endCallback(this.currentCaptchaData,this);
}
});
if (loadSuccessCallback) {
// 加载成功
loadSuccessCallback(this);
}
return this;
}
updateOrderHint() {
const hintEl = this.el.find("#word-order-hint");
const count = this.currentCaptchaData.clickCount || 0;
const maxCount = this.currentCaptchaData.maxClickCount || 4;
let html = '';
for (let i = 1; i <= maxCount; i++) {
if (i <= count) {
html += '<span class="hint-dot active">' + i + '</span>';
} else {
html += '<span class="hint-dot">' + i + '</span>';
}
}
hintEl.html(html);
}
destroy () {
const existsCaptchaEl = this.boxEl.children("#tianai-captcha");
if (existsCaptchaEl) {
existsCaptchaEl.remove();
}
destroyEvent();
}
loadCaptchaForData (that, data) {
const bgImg = that.el.find("#tianai-captcha-slider-bg-img");
bgImg.on("load",() => {
that.currentCaptchaData = initConfig(bgImg.width(), bgImg.height(), 0, 0);
that.currentCaptchaData.currentCaptchaId = data.data.id;
that.currentCaptchaData.maxClickCount = data.data.maxClickCount || 4;
})
bgImg.attr("src", data.data.backgroundImage);
}
}
export default WordOrderClick;
@@ -0,0 +1,105 @@
#tianai-captcha.tianai-captcha-word-order-click {
box-sizing: border-box;
.click-tip {
position: relative;
height: 40px;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
#tianai-captcha-click-track-font {
font-size: 14px;
color: #333;
}
.word-order-hint {
display: flex;
gap: 4px;
.hint-dot {
width: 20px;
height: 20px;
border-radius: 50%;
background-color: #e0e0e0;
color: #999;
font-size: 11px;
line-height: 20px;
text-align: center;
transition: all 0.2s;
&.active {
background-color: #409eff;
color: #fff;
}
}
}
}
.slider-bottom {
position: relative;
top: 6px;
}
.content {
#bg-img-click-mask {
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
cursor: pointer;
.click-span {
position: absolute;
left: 0;
top: 0;
border-radius: 50%;
background-color: #409eff;
width: 24px;
height: 24px;
text-align: center;
line-height: 24px;
color: #fff;
border: 2px solid #fff;
box-sizing: content-box;
font-size: 12px;
font-weight: bold;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
}
}
.click-confirm-btn {
width: 100%;
height: 35px;
border-radius: 4px;
background-image: linear-gradient(173deg, hsl(38.09deg 91% 57.89%) 0%, hsl(38.09deg 89.38% 71.74%) 100%);
font-size: 15px;
text-align: center;
box-sizing: border-box;
line-height: 35px;
color: #fff;
margin-top: 3px;
cursor: pointer;
transition: opacity 0.2s;
&:hover {
opacity: 0.9;
}
}
.click-reset-btn {
position: absolute;
right: 0;
top: 0;
font-size: 12px;
color: #409eff;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}