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:
@@ -32,3 +32,4 @@ node_modules/
|
|||||||
*.log
|
*.log
|
||||||
.omo/
|
.omo/
|
||||||
.codegraph/
|
.codegraph/
|
||||||
|
tianai-captcha-platform-ui/dist/
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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;"]
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
transition: opacity 0.4s ease;
|
transition: opacity 0.4s ease;
|
||||||
}
|
}
|
||||||
.ward-pulse {
|
.ward-pulse {
|
||||||
|
opacity: 0;
|
||||||
transform-origin: 50px 50px;
|
transform-origin: 50px 50px;
|
||||||
animation: pulse-ward 2s cubic-bezier(0.1, 0.7, 0.3, 1) infinite;
|
animation: pulse-ward 2s cubic-bezier(0.1, 0.7, 0.3, 1) infinite;
|
||||||
}
|
}
|
||||||
@@ -49,7 +50,7 @@
|
|||||||
|
|
||||||
/* 移除了夸张的缩放和粗细变化,改为更清爽的光圈扩散 */
|
/* 移除了夸张的缩放和粗细变化,改为更清爽的光圈扩散 */
|
||||||
@keyframes pulse-ward {
|
@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; }
|
100% { transform: scale(1.8); opacity: 0; stroke-width: 1px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -8,14 +8,16 @@
|
|||||||
"name": "tianai-captcha-platform-ui",
|
"name": "tianai-captcha-platform-ui",
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.0",
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
"naive-ui": "^2.40.0",
|
"axios": "^1.7.9",
|
||||||
"pinia": "^2.2.0",
|
"echarts": "^5.5.1",
|
||||||
"vue": "^3.5.0",
|
"element-plus": "^2.9.1",
|
||||||
"vue-router": "^4.4.0"
|
"pinia": "^2.3.0",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-router": "^4.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.2.0",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
"vite": "^6.0.0"
|
"vite": "^6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -65,30 +67,24 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@css-render/plugin-bem": {
|
"node_modules/@ctrl/tinycolor": {
|
||||||
"version": "0.15.14",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmmirror.com/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz",
|
"resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz",
|
||||||
"integrity": "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==",
|
"integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"engines": {
|
||||||
"css-render": "~0.15.14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@css-render/vue3-ssr": {
|
"node_modules/@element-plus/icons-vue": {
|
||||||
"version": "0.15.14",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmmirror.com/@css-render/vue3-ssr/-/vue3-ssr-0.15.14.tgz",
|
"resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz",
|
||||||
"integrity": "sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==",
|
"integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||||
@@ -531,17 +527,47 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.5.5",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@juggle/resize-observer": {
|
"node_modules/@popperjs/core": {
|
||||||
"version": "3.4.0",
|
"name": "@sxzz/popperjs-es",
|
||||||
"resolved": "https://registry.npmmirror.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
|
"version": "2.11.8",
|
||||||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==",
|
"resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz",
|
||||||
"license": "Apache-2.0"
|
"integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/popperjs"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.62.2",
|
"version": "4.62.2",
|
||||||
@@ -954,6 +980,12 @@
|
|||||||
"@types/lodash": "*"
|
"@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": {
|
"node_modules/@vitejs/plugin-vue": {
|
||||||
"version": "5.2.4",
|
"version": "5.2.4",
|
||||||
"resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
|
"resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
|
||||||
@@ -1074,6 +1106,44 @@
|
|||||||
"integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==",
|
"integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/agent-base": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
@@ -1135,46 +1205,17 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/date-fns": {
|
"node_modules/dayjs": {
|
||||||
"version": "4.4.0",
|
"version": "1.11.23",
|
||||||
"resolved": "https://registry.npmmirror.com/date-fns/-/date-fns-4.4.0.tgz",
|
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.23.tgz",
|
||||||
"integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
|
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
|
||||||
"license": "MIT",
|
"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/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
@@ -1216,6 +1257,42 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/entities": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
|
||||||
@@ -1321,12 +1398,6 @@
|
|||||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
|
||||||
@@ -1493,15 +1564,6 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/https-proxy-agent": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
"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==",
|
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
@@ -1545,6 +1618,12 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/mime-db": {
|
||||||
"version": "1.52.0",
|
"version": "1.52.0",
|
||||||
"resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
|
"resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
@@ -1572,38 +1651,6 @@
|
|||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.16",
|
"version": "3.3.16",
|
||||||
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.16.tgz",
|
"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": "^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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -1745,12 +1798,6 @@
|
|||||||
"fsevents": "~2.3.2"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
|
"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"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/treemate": {
|
"node_modules/tslib": {
|
||||||
"version": "0.3.11",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmmirror.com/treemate/-/treemate-0.3.11.tgz",
|
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
|
||||||
"integrity": "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==",
|
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||||
"license": "MIT"
|
"license": "0BSD"
|
||||||
},
|
|
||||||
"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/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.3",
|
"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": {
|
"node_modules/vue": {
|
||||||
"version": "3.5.39",
|
"version": "3.5.39",
|
||||||
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.39.tgz",
|
"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": {
|
"node_modules/vue-demi": {
|
||||||
"version": "0.14.10",
|
"version": "0.14.10",
|
||||||
"resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz",
|
"resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz",
|
||||||
@@ -1944,22 +1973,13 @@
|
|||||||
"vue": "^3.5.0"
|
"vue": "^3.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vueuc": {
|
"node_modules/zrender": {
|
||||||
"version": "0.4.65",
|
"version": "5.6.1",
|
||||||
"resolved": "https://registry.npmmirror.com/vueuc/-/vueuc-0.4.65.tgz",
|
"resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.6.1.tgz",
|
||||||
"integrity": "sha512-lXuMl+8gsBmruudfxnMF9HW4be8rFziylXFu1VHVNbLVhRTXXV4njvpRuJapD/8q+oFEMSfQMH16E/85VoWRyQ==",
|
"integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
|
||||||
"license": "MIT",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@css-render/vue3-ssr": "^0.15.10",
|
"tslib": "2.3.0"
|
||||||
"@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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,16 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vue": "^3.5.0",
|
"vue": "^3.5.13",
|
||||||
"naive-ui": "^2.40.0",
|
"vue-router": "^4.5.0",
|
||||||
"axios": "^1.7.0",
|
"pinia": "^2.3.0",
|
||||||
"vue-router": "^4.4.0",
|
"element-plus": "^2.9.1",
|
||||||
"pinia": "^2.2.0"
|
"axios": "^1.7.9",
|
||||||
|
"echarts": "^5.5.1",
|
||||||
|
"@element-plus/icons-vue": "^2.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.2.0",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
"vite": "^6.0.0"
|
"vite": "^6.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-config-provider>
|
|
||||||
<!-- 测试页:全屏,不带侧边栏 -->
|
|
||||||
<div v-if="route.path === '/test'" style="height:100vh;">
|
|
||||||
<router-view />
|
<router-view />
|
||||||
</div>
|
|
||||||
<!-- 其他页面:带侧边栏 -->
|
|
||||||
<n-layout v-else has-sider style="height:100vh">
|
|
||||||
<n-layout-sider bordered :width="220">
|
|
||||||
<div style="padding:16px 20px;font-size:18px;font-weight:700;color:#1890ff;">tianai-captcha</div>
|
|
||||||
<n-menu :options="menuOptions" :value="currentRoute" @update:value="onMenuSelect" />
|
|
||||||
</n-layout-sider>
|
|
||||||
<n-layout>
|
|
||||||
<n-layout-header bordered style="padding:12px 24px;font-size:16px;font-weight:500;">
|
|
||||||
验证码管理平台
|
|
||||||
</n-layout-header>
|
|
||||||
<n-layout-content style="padding:24px;">
|
|
||||||
<router-view />
|
|
||||||
</n-layout-content>
|
|
||||||
</n-layout>
|
|
||||||
</n-layout>
|
|
||||||
</n-config-provider>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<style>
|
||||||
import { computed } from 'vue'
|
* {
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
margin: 0;
|
||||||
import { NConfigProvider, NLayout, NLayoutSider, NLayoutHeader, NLayoutContent, NMenu } from 'naive-ui'
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
const router = useRouter()
|
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
const currentRoute = computed(() => route.path)
|
|
||||||
|
|
||||||
const menuOptions = [
|
|
||||||
{ label: '仪表盘', key: '/' },
|
|
||||||
{ label: '站点管理', key: '/sites' },
|
|
||||||
{ label: '统计监控', key: '/stats' },
|
|
||||||
{ label: '验证测试', key: '/test' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function onMenuSelect(key) {
|
|
||||||
router.push(key)
|
|
||||||
}
|
}
|
||||||
</script>
|
|
||||||
|
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>
|
||||||
@@ -1,21 +1,19 @@
|
|||||||
import { createApp } from 'vue'
|
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 App from './App.vue'
|
||||||
import Dashboard from './views/Dashboard.vue'
|
import router from './router'
|
||||||
import Sites from './views/Sites.vue'
|
|
||||||
import Stats from './views/Stats.vue'
|
|
||||||
import CaptchaTest from './views/CaptchaTest.vue'
|
|
||||||
|
|
||||||
const routes = [
|
const app = createApp(App)
|
||||||
{ path: '/', component: Dashboard },
|
|
||||||
{ path: '/sites', component: Sites },
|
|
||||||
{ path: '/stats', component: Stats },
|
|
||||||
{ path: '/test', component: CaptchaTest },
|
|
||||||
]
|
|
||||||
|
|
||||||
const router = createRouter({
|
// 注册所有 Element Plus 图标
|
||||||
history: createWebHistory(),
|
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||||
routes,
|
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:
|
||||||
@@ -51,6 +51,46 @@
|
|||||||
<groupId>com.google.code.gson</groupId>
|
<groupId>com.google.code.gson</groupId>
|
||||||
<artifactId>gson</artifactId>
|
<artifactId>gson</artifactId>
|
||||||
</dependency>
|
</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>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@@ -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);
|
||||||
+100
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+67
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+101
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+60
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+60
@@ -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; }
|
||||||
|
}
|
||||||
+24
@@ -2,7 +2,31 @@ package cloud.tianai.captcha.platform.mapper;
|
|||||||
|
|
||||||
import cloud.tianai.captcha.platform.entity.CaptchaLog;
|
import cloud.tianai.captcha.platform.entity.CaptchaLog;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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> {
|
public interface CaptchaLogRepository extends JpaRepository<CaptchaLog, Integer> {
|
||||||
long countByIsPass(Boolean isPass);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
+10
@@ -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();
|
||||||
|
}
|
||||||
+11
@@ -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);
|
||||||
|
}
|
||||||
+97
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+99
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+72
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+106
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
+165
@@ -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-enabled: true
|
||||||
local-cache-size: 20
|
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:
|
logging:
|
||||||
level:
|
level:
|
||||||
cloud.tianai.captcha: INFO
|
cloud.tianai.captcha: INFO
|
||||||
|
|||||||
+74
@@ -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
|
||||||
@@ -111,9 +111,9 @@
|
|||||||
'.tacaptcha-dot{width:8px;height:8px;border-radius:50%;background:#52c41a;animation:tacaptcha-breathe 2s ease-in-out infinite}' +
|
'.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 .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-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}' +
|
'.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-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-a{transform-origin:50px 50px;transform:rotate(-45deg)}' +
|
||||||
'.tacaptcha-shield .sword-anchor-b{transform-origin:50px 50px;transform:rotate(45deg)}' +
|
'.tacaptcha-shield .sword-anchor-b{transform-origin:50px 50px;transform:rotate(45deg)}' +
|
||||||
|
|||||||
@@ -1,101 +1,325 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="zh-CN">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Document</title>
|
<title>TianAI Captcha - 行为验证码演示</title>
|
||||||
<style>
|
<style>
|
||||||
#login-div {
|
* {
|
||||||
width: 500px;
|
margin: 0;
|
||||||
height: 500px;
|
padding: 0;
|
||||||
border: 1px solid #ff5d39;
|
box-sizing: border-box;
|
||||||
position: relative;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
body {
|
||||||
text-align: center;
|
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 {
|
.demo-container {
|
||||||
height: 50px;
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
overflow: hidden;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid #ccc;
|
max-width: 480px;
|
||||||
border-radius: 6px;
|
|
||||||
margin: 20px auto;
|
|
||||||
color: #ccc;
|
|
||||||
line-height: 50px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-left {
|
.demo-header {
|
||||||
border-right: 1px solid #ccc;
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
text-align: center;
|
|
||||||
width: 100px;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-btn {
|
|
||||||
/*margin: 0 auto;*/
|
|
||||||
display: inline-block;
|
|
||||||
width: 160px;
|
|
||||||
height: 50px;
|
|
||||||
background-color: #4BC065;
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
line-height: 50px;
|
padding: 30px;
|
||||||
text-align: center;
|
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 {
|
#captcha-box {
|
||||||
position: absolute;
|
position: relative;
|
||||||
left: 78px;
|
}
|
||||||
top: 83px;
|
|
||||||
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<!-- 验证码存放的div块 -->
|
<div class="demo-container">
|
||||||
<div id="login-div">
|
<div class="demo-header">
|
||||||
<!-- 装载验证码的DIV -->
|
<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 id="captcha-box"></div>
|
||||||
<h1>用户登录</h1>
|
|
||||||
<div class="input">
|
|
||||||
<span class="input-left">用户名</span>
|
|
||||||
xxxxx
|
|
||||||
</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>
|
<div class="login-form">
|
||||||
document.querySelectorAll(".login-btn").forEach(el => {
|
<div class="form-group">
|
||||||
el.addEventListener("click", e => {
|
<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>
|
||||||
|
|
||||||
|
<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 = '';
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
requestCaptchaDataUrl: "http://localhost:8080/gen?type=" + el.dataset.type,
|
requestCaptchaDataUrl: `http://localhost:8080/gen?type=${type}`,
|
||||||
validCaptchaUrl: "http://localhost:8080/check",
|
validCaptchaUrl: "http://localhost:8080/check",
|
||||||
bindEl: "#captcha-box"
|
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 style = {
|
};
|
||||||
// logoUrl : null
|
|
||||||
//
|
|
||||||
// }
|
|
||||||
|
|
||||||
const captcha = new TAC(config, null);
|
const style = {
|
||||||
captcha.init();
|
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>
|
</body>
|
||||||
|
|
||||||
</html>
|
</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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,14 @@ import Rotate from "./rotate/rotate";
|
|||||||
import Concat from "./concat/concat";
|
import Concat from "./concat/concat";
|
||||||
import Disable from "./disable/disable";
|
import Disable from "./disable/disable";
|
||||||
import WordImageClick from "./word_image_click/word_image_click";
|
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 {CaptchaConfig, wrapConfig, wrapStyle} from "./config/config";
|
||||||
import {clearAllPreventDefault} from "./common/common";
|
import {clearAllPreventDefault} from "./common/common";
|
||||||
const template =
|
const template =
|
||||||
@@ -11,13 +19,30 @@ const template =
|
|||||||
<div id="tianai-captcha-parent">
|
<div id="tianai-captcha-parent">
|
||||||
<div id="tianai-captcha-bg-img"></div>
|
<div id="tianai-captcha-bg-img"></div>
|
||||||
<div id="tianai-captcha-box">
|
<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>
|
||||||
<!-- 底部 -->
|
<!-- 底部 -->
|
||||||
<div class="slider-bottom">
|
<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="bottom-left">
|
||||||
<div class="close-btn" id="tianai-captcha-slider-close-btn"></div>
|
<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 class="refresh-btn" id="tianai-captcha-slider-refresh-btn"></div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -33,6 +58,22 @@ function createCaptchaByType(type, tac) {
|
|||||||
return new Concat(box, styleConfig);
|
return new Concat(box, styleConfig);
|
||||||
case "WORD_IMAGE_CLICK":
|
case "WORD_IMAGE_CLICK":
|
||||||
return new WordImageClick(box, styleConfig);
|
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":
|
case "DISABLED":
|
||||||
return new Disable(box, styleConfig);
|
return new Disable(box, styleConfig);
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -1,39 +1,58 @@
|
|||||||
#tianai-captcha-parent {
|
#tianai-captcha-parent {
|
||||||
box-shadow: 0 0 11px 0 #999999;
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
||||||
width: 318px;
|
width: 318px;
|
||||||
height: 318px;
|
height: 318px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 997;
|
z-index: 997;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border-radius: 5px;
|
border-radius: 8px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
|
|
||||||
#tianai-captcha-box {
|
#tianai-captcha-box {
|
||||||
height: 260px;
|
height: 260px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
border-radius: 6px;
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
width: 120px;
|
width: 100%;
|
||||||
height: 20px;
|
height: 100%;
|
||||||
-webkit-mask: linear-gradient(90deg, #000 70%, #0000 0) 0/20%;
|
display: flex;
|
||||||
background: linear-gradient(#f7b645 0 0) 0 / 0% no-repeat #dddddd6b;
|
flex-direction: column;
|
||||||
animation: cartoon 1s infinite steps(6);
|
justify-content: center;
|
||||||
margin: 120px auto;
|
align-items: center;
|
||||||
@keyframes cartoon {
|
background: #fafafa;
|
||||||
100% {
|
|
||||||
background-size: 120%;
|
.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 {
|
#tianai-captcha {
|
||||||
transform-style: preserve-3d;
|
transform-style: preserve-3d;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
transition-duration: .45s;
|
transition-duration: 0.45s;
|
||||||
//transition-timing-function: cubic-bezier(0.36, 0.3, 0.42, 1.5);
|
|
||||||
transform: translateX(-300px);
|
transform: translateX(-300px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#tianai-captcha-bg-img {
|
#tianai-captcha-bg-img {
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
background-position: top;
|
background-position: top;
|
||||||
@@ -44,11 +63,54 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
//background-image: url("");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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 {
|
.close-btn {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
@@ -59,6 +121,7 @@
|
|||||||
margin-right: 2px;
|
margin-right: 2px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.refresh-btn {
|
.refresh-btn {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
@@ -69,13 +132,16 @@
|
|||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
height: 30px;
|
height: 30px;
|
||||||
float: left;
|
float: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
height: 19px;
|
height: 19px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider-move-shadow {
|
.slider-move-shadow {
|
||||||
animation: myanimation 2s infinite;
|
animation: myanimation 2s infinite;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -88,20 +154,18 @@
|
|||||||
box-shadow: 1px 1px 1px #fff;
|
box-shadow: 1px 1px 1px #fff;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
#tianai-captcha-slider-move-track-mask {
|
#tianai-captcha-slider-move-track-mask {
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-color: #00f4ab;
|
border-color: #00f4ab;
|
||||||
|
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
background-color: #a9ffe5;
|
background-color: #a9ffe5;
|
||||||
opacity: .5;
|
opacity: 0.5;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -1px;
|
top: -1px;
|
||||||
left: -1px;
|
left: -1px;
|
||||||
border-radius: 5px;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user