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

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

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

Phase 5: 部署和测试
- docker-compose.yml 5服务编排
- application-prod.yml 外部化配置
- CaptchaApiIntegrationTest 集成测试
- 前端 Dockerfile + nginx.conf
This commit is contained in:
abcv7
2026-08-26 13:44:08 +08:00
parent d1cd371061
commit 1164b51c64
67 changed files with 5510 additions and 829 deletions
+12
View File
@@ -0,0 +1,12 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+2 -1
View File
@@ -40,6 +40,7 @@
transition: opacity 0.4s ease;
}
.ward-pulse {
opacity: 0;
transform-origin: 50px 50px;
animation: pulse-ward 2s cubic-bezier(0.1, 0.7, 0.3, 1) infinite;
}
@@ -49,7 +50,7 @@
/* 移除了夸张的缩放和粗细变化,改为更清爽的光圈扩散 */
@keyframes pulse-ward {
0% { transform: scale(0.6); opacity: 1; stroke-width: 6px; }
0% { transform: scale(1.0); opacity: 1; stroke-width: 6px; }
100% { transform: scale(1.8); opacity: 0; stroke-width: 1px; }
}
+17
View File
@@ -0,0 +1,17 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://captcha-platform:18200/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
+179 -159
View File
@@ -8,14 +8,16 @@
"name": "tianai-captcha-platform-ui",
"version": "2.0.0",
"dependencies": {
"axios": "^1.7.0",
"naive-ui": "^2.40.0",
"pinia": "^2.2.0",
"vue": "^3.5.0",
"vue-router": "^4.4.0"
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.9",
"echarts": "^5.5.1",
"element-plus": "^2.9.1",
"pinia": "^2.3.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.0",
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^6.0.0"
}
},
@@ -65,30 +67,24 @@
"node": ">=6.9.0"
}
},
"node_modules/@css-render/plugin-bem": {
"version": "0.15.14",
"resolved": "https://registry.npmmirror.com/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz",
"integrity": "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==",
"node_modules/@ctrl/tinycolor": {
"version": "4.2.0",
"resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz",
"integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==",
"license": "MIT",
"peerDependencies": {
"css-render": "~0.15.14"
"engines": {
"node": ">=14"
}
},
"node_modules/@css-render/vue3-ssr": {
"version": "0.15.14",
"resolved": "https://registry.npmmirror.com/@css-render/vue3-ssr/-/vue3-ssr-0.15.14.tgz",
"integrity": "sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==",
"node_modules/@element-plus/icons-vue": {
"version": "2.3.2",
"resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz",
"integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==",
"license": "MIT",
"peerDependencies": {
"vue": "^3.0.11"
"vue": "^3.2.0"
}
},
"node_modules/@emotion/hash": {
"version": "0.8.0",
"resolved": "https://registry.npmmirror.com/@emotion/hash/-/hash-0.8.0.tgz",
"integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==",
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -531,17 +527,47 @@
"node": ">=18"
}
},
"node_modules/@floating-ui/core": {
"version": "1.8.0",
"resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.8.0.tgz",
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.8.0",
"resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.8.0.tgz",
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.8.0",
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.12",
"resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.12.tgz",
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT"
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@juggle/resize-observer": {
"version": "3.4.0",
"resolved": "https://registry.npmmirror.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==",
"license": "Apache-2.0"
"node_modules/@popperjs/core": {
"name": "@sxzz/popperjs-es",
"version": "2.11.8",
"resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz",
"integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.62.2",
@@ -954,6 +980,12 @@
"@types/lodash": "*"
}
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": {
"version": "5.2.4",
"resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
@@ -1074,6 +1106,44 @@
"integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==",
"license": "MIT"
},
"node_modules/@vueuse/core": {
"version": "14.4.0",
"resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.4.0.tgz",
"integrity": "sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "14.4.0",
"@vueuse/shared": "14.4.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/metadata": {
"version": "14.4.0",
"resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.4.0.tgz",
"integrity": "sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "14.4.0",
"resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.4.0.tgz",
"integrity": "sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz",
@@ -1135,46 +1205,17 @@
"node": ">= 0.8"
}
},
"node_modules/css-render": {
"version": "0.15.14",
"resolved": "https://registry.npmmirror.com/css-render/-/css-render-0.15.14.tgz",
"integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==",
"license": "MIT",
"dependencies": {
"@emotion/hash": "~0.8.0",
"csstype": "~3.0.5"
}
},
"node_modules/css-render/node_modules/csstype": {
"version": "3.0.11",
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.0.11.tgz",
"integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==",
"license": "MIT"
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/date-fns": {
"version": "4.4.0",
"resolved": "https://registry.npmmirror.com/date-fns/-/date-fns-4.4.0.tgz",
"integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/date-fns-tz": {
"version": "3.2.0",
"resolved": "https://registry.npmmirror.com/date-fns-tz/-/date-fns-tz-3.2.0.tgz",
"integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==",
"license": "MIT",
"peerDependencies": {
"date-fns": "^3.0.0 || ^4.0.0"
}
"node_modules/dayjs": {
"version": "1.11.23",
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.23.tgz",
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
@@ -1216,6 +1257,42 @@
"node": ">= 0.4"
}
},
"node_modules/echarts": {
"version": "5.6.0",
"resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz",
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "5.6.1"
}
},
"node_modules/element-plus": {
"version": "2.14.5",
"resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.14.5.tgz",
"integrity": "sha512-bghYy/S+qg87enHPXELirhEdDqsVAUGcGpbGIeG8dz0kwpIkGz7gYsifulBshXX74iRtHib85XWQj0uSH2A1Yg==",
"license": "MIT",
"dependencies": {
"@ctrl/tinycolor": "^4.2.0",
"@element-plus/icons-vue": "^2.3.2",
"@floating-ui/dom": "^1.8.0",
"@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8",
"@types/lodash": "^4.17.24",
"@types/lodash-es": "^4.17.12",
"@vueuse/core": "14.4.0",
"async-validator": "^4.2.5",
"dayjs": "^1.11.20",
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"lodash-unified": "^1.0.3",
"memoize-one": "^6.0.0",
"normalize-wheel-es": "^1.2.0",
"vue-component-type-helpers": "^3.3.9"
},
"peerDependencies": {
"vue": "^3.3.7"
}
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
@@ -1321,12 +1398,6 @@
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT"
},
"node_modules/evtd": {
"version": "0.2.4",
"resolved": "https://registry.npmmirror.com/evtd/-/evtd-0.2.4.tgz",
"integrity": "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==",
"license": "MIT"
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
@@ -1493,15 +1564,6 @@
"node": ">= 0.4"
}
},
"node_modules/highlight.js": {
"version": "11.11.1",
"resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz",
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
@@ -1527,6 +1589,17 @@
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/lodash-unified": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz",
"integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==",
"license": "MIT",
"peerDependencies": {
"@types/lodash-es": "*",
"lodash": "*",
"lodash-es": "*"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
@@ -1545,6 +1618,12 @@
"node": ">= 0.4"
}
},
"node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
@@ -1572,38 +1651,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/naive-ui": {
"version": "2.44.1",
"resolved": "https://registry.npmmirror.com/naive-ui/-/naive-ui-2.44.1.tgz",
"integrity": "sha512-reo8Esw0p58liZwbUutC7meW24Xbn3EwNv91zReWKm2W4JPu+zfgJRn/F7aO0BFmvN+h2brA2M5lRvYqLq4kuA==",
"license": "MIT",
"dependencies": {
"@css-render/plugin-bem": "^0.15.14",
"@css-render/vue3-ssr": "^0.15.14",
"@types/lodash": "^4.17.20",
"@types/lodash-es": "^4.17.12",
"async-validator": "^4.2.5",
"css-render": "^0.15.14",
"csstype": "^3.1.3",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"evtd": "^0.2.4",
"highlight.js": "^11.8.0",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"seemly": "^0.3.10",
"treemate": "^0.3.11",
"vdirs": "^0.1.8",
"vooks": "^0.2.12",
"vueuc": "^0.4.65"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.16.tgz",
@@ -1622,6 +1669,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/normalize-wheel-es": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
"integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
"license": "BSD-3-Clause"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
@@ -1745,12 +1798,6 @@
"fsevents": "~2.3.2"
}
},
"node_modules/seemly": {
"version": "0.3.10",
"resolved": "https://registry.npmmirror.com/seemly/-/seemly-0.3.10.tgz",
"integrity": "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1777,23 +1824,11 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/treemate": {
"version": "0.3.11",
"resolved": "https://registry.npmmirror.com/treemate/-/treemate-0.3.11.tgz",
"integrity": "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==",
"license": "MIT"
},
"node_modules/vdirs": {
"version": "0.1.8",
"resolved": "https://registry.npmmirror.com/vdirs/-/vdirs-0.1.8.tgz",
"integrity": "sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==",
"license": "MIT",
"dependencies": {
"evtd": "^0.2.2"
},
"peerDependencies": {
"vue": "^3.0.11"
}
"node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/vite": {
"version": "6.4.3",
@@ -1870,18 +1905,6 @@
}
}
},
"node_modules/vooks": {
"version": "0.2.12",
"resolved": "https://registry.npmmirror.com/vooks/-/vooks-0.2.12.tgz",
"integrity": "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==",
"license": "MIT",
"dependencies": {
"evtd": "^0.2.2"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/vue": {
"version": "3.5.39",
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.39.tgz",
@@ -1903,6 +1926,12 @@
}
}
},
"node_modules/vue-component-type-helpers": {
"version": "3.3.11",
"resolved": "https://registry.npmmirror.com/vue-component-type-helpers/-/vue-component-type-helpers-3.3.11.tgz",
"integrity": "sha512-LwcxzeliO9fkQcpJG0PoX8X5kmAhKmH9wkpDLxNabwzkQ9Zeib2YVHwFV4pcWmMLfXVfjr/dSV+DaJ3cIPgSNA==",
"license": "MIT"
},
"node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz",
@@ -1944,22 +1973,13 @@
"vue": "^3.5.0"
}
},
"node_modules/vueuc": {
"version": "0.4.65",
"resolved": "https://registry.npmmirror.com/vueuc/-/vueuc-0.4.65.tgz",
"integrity": "sha512-lXuMl+8gsBmruudfxnMF9HW4be8rFziylXFu1VHVNbLVhRTXXV4njvpRuJapD/8q+oFEMSfQMH16E/85VoWRyQ==",
"license": "MIT",
"node_modules/zrender": {
"version": "5.6.1",
"resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.6.1.tgz",
"integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
"license": "BSD-3-Clause",
"dependencies": {
"@css-render/vue3-ssr": "^0.15.10",
"@juggle/resize-observer": "^3.3.1",
"css-render": "^0.15.10",
"evtd": "^0.2.4",
"seemly": "^0.3.6",
"vdirs": "^0.1.4",
"vooks": "^0.2.4"
},
"peerDependencies": {
"vue": "^3.0.11"
"tslib": "2.3.0"
}
}
}
+8 -6
View File
@@ -9,14 +9,16 @@
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.0",
"naive-ui": "^2.40.0",
"axios": "^1.7.0",
"vue-router": "^4.4.0",
"pinia": "^2.2.0"
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"pinia": "^2.3.0",
"element-plus": "^2.9.1",
"axios": "^1.7.9",
"echarts": "^5.5.1",
"@element-plus/icons-vue": "^2.3.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.0",
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^6.0.0"
}
}
+12 -41
View File
@@ -1,45 +1,16 @@
<template>
<n-config-provider>
<!-- 测试页全屏不带侧边栏 -->
<div v-if="route.path === '/test'" style="height:100vh;">
<router-view />
</div>
<!-- 其他页面带侧边栏 -->
<n-layout v-else has-sider style="height:100vh">
<n-layout-sider bordered :width="220">
<div style="padding:16px 20px;font-size:18px;font-weight:700;color:#1890ff;">tianai-captcha</div>
<n-menu :options="menuOptions" :value="currentRoute" @update:value="onMenuSelect" />
</n-layout-sider>
<n-layout>
<n-layout-header bordered style="padding:12px 24px;font-size:16px;font-weight:500;">
验证码管理平台
</n-layout-header>
<n-layout-content style="padding:24px;">
<router-view />
</n-layout-content>
</n-layout>
</n-layout>
</n-config-provider>
<router-view />
</template>
<script setup>
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { NConfigProvider, NLayout, NLayoutSider, NLayoutHeader, NLayoutContent, NMenu } from 'naive-ui'
const router = useRouter()
const route = useRoute()
const currentRoute = computed(() => route.path)
const menuOptions = [
{ label: '仪表盘', key: '/' },
{ label: '站点管理', key: '/sites' },
{ label: '统计监控', key: '/stats' },
{ label: '验证测试', key: '/test' },
]
function onMenuSelect(key) {
router.push(key)
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
</script>
html, body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
</style>
@@ -0,0 +1,81 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
const api = axios.create({
baseURL: '/api',
timeout: 10000
})
// 请求拦截器
api.interceptors.request.use(
config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
error => Promise.reject(error)
)
// 响应拦截器
api.interceptors.response.use(
response => {
const data = response.data
if (data.code && data.code !== 200) {
ElMessage.error(data.msg || '请求失败')
return Promise.reject(data)
}
return data
},
error => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
window.location.href = '/login'
}
ElMessage.error(error.message || '网络错误')
return Promise.reject(error)
}
)
// 认证 API
export const authApi = {
login: (data) => api.post('/auth/login', data),
getMe: () => api.get('/auth/me')
}
// 站点 API
export const siteApi = {
list: (params) => api.get('/admin/sites', { params }),
get: (id) => api.get(`/admin/sites/${id}`),
create: (data) => api.post('/admin/sites', data),
update: (id, data) => api.put(`/admin/sites/${id}`, data),
delete: (id) => api.delete(`/admin/sites/${id}`)
}
// 统计 API
export const statsApi = {
get: (params) => api.get('/admin/stats', { params })
}
// 日志 API
export const logApi = {
list: (params) => api.get('/admin/logs', { params })
}
// IP 黑名单 API
export const ipBlacklistApi = {
ban: (data) => api.post('/admin/ip-blacklist', data),
unban: (ip) => api.delete(`/admin/ip-blacklist/${ip}`)
}
// 套餐 API
export const planApi = {
list: () => api.get('/admin/plans'),
get: (id) => api.get(`/admin/plans/${id}`),
create: (data) => api.post('/admin/plans', data),
update: (id, data) => api.put(`/admin/plans/${id}`, data),
delete: (id) => api.delete(`/admin/plans/${id}`)
}
export default api
@@ -0,0 +1,129 @@
<template>
<el-container class="main-layout">
<el-aside width="220px" class="sidebar">
<div class="logo">
<h2>TianAI Captcha</h2>
</div>
<el-menu
:default-active="route.path"
router
background-color="#304156"
text-color="#bfcbd9"
active-text-color="#409eff"
>
<el-menu-item index="/dashboard">
<el-icon><DataBoard /></el-icon>
<span>首页大盘</span>
</el-menu-item>
<el-menu-item index="/sites">
<el-icon><OfficeBuilding /></el-icon>
<span>站点管理</span>
</el-menu-item>
<el-menu-item index="/logs">
<el-icon><Document /></el-icon>
<span>验证码日志</span>
</el-menu-item>
<el-menu-item index="/ip-blacklist">
<el-icon><CircleCloseFilled /></el-icon>
<span>IP黑名单</span>
</el-menu-item>
<el-menu-item index="/plans">
<el-icon><Ticket /></el-icon>
<span>套餐管理</span>
</el-menu-item>
<el-menu-item index="/settings">
<el-icon><Setting /></el-icon>
<span>系统设置</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header class="header">
<div class="header-left">
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item>{{ route.meta.title }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-right">
<el-dropdown @command="handleCommand">
<span class="user-info">
<el-icon><User /></el-icon>
{{ userStore.username }}
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<el-main class="main-content">
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup>
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '../store/user'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const handleCommand = (command) => {
if (command === 'logout') {
userStore.logout()
router.push('/login')
}
}
</script>
<style scoped>
.main-layout {
height: 100vh;
}
.sidebar {
background-color: #304156;
overflow-y: auto;
}
.logo {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
background-color: #263445;
}
.logo h2 {
color: #fff;
font-size: 18px;
margin: 0;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
}
.user-info {
display: flex;
align-items: center;
cursor: pointer;
color: #606266;
}
.main-content {
background-color: #f0f2f5;
padding: 20px;
}
</style>
+14 -16
View File
@@ -1,21 +1,19 @@
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import Dashboard from './views/Dashboard.vue'
import Sites from './views/Sites.vue'
import Stats from './views/Stats.vue'
import CaptchaTest from './views/CaptchaTest.vue'
import router from './router'
const routes = [
{ path: '/', component: Dashboard },
{ path: '/sites', component: Sites },
{ path: '/stats', component: Stats },
{ path: '/test', component: CaptchaTest },
]
const app = createApp(App)
const router = createRouter({
history: createWebHistory(),
routes,
})
// 注册所有 Element Plus 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
createApp(App).use(router).mount('#app')
app.use(ElementPlus)
app.use(createPinia())
app.use(router)
app.mount('#app')
@@ -0,0 +1,69 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('../views/login/Login.vue')
},
{
path: '/',
component: () => import('../layouts/MainLayout.vue'),
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('../views/dashboard/Dashboard.vue'),
meta: { title: '首页大盘' }
},
{
path: 'sites',
name: 'Sites',
component: () => import('../views/sites/Sites.vue'),
meta: { title: '站点管理' }
},
{
path: 'logs',
name: 'Logs',
component: () => import('../views/logs/Logs.vue'),
meta: { title: '验证码日志' }
},
{
path: 'ip-blacklist',
name: 'IpBlacklist',
component: () => import('../views/ip-blacklist/IpBlacklist.vue'),
meta: { title: 'IP黑名单' }
},
{
path: 'plans',
name: 'Plans',
component: () => import('../views/plans/Plans.vue'),
meta: { title: '套餐管理' }
},
{
path: 'settings',
name: 'Settings',
component: () => import('../views/settings/Settings.vue'),
meta: { title: '系统设置' }
}
]
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// 路由守卫
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
if (to.path !== '/login' && !token) {
next('/login')
} else {
next()
}
})
export default router
@@ -0,0 +1,45 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { authApi } from '../api'
export const useUserStore = defineStore('user', () => {
const token = ref(localStorage.getItem('token') || '')
const username = ref(localStorage.getItem('username') || '')
const role = ref(localStorage.getItem('role') || '')
const login = async (loginData) => {
const res = await authApi.login(loginData)
if (res.data) {
token.value = res.data.token
username.value = res.data.username
role.value = res.data.role
localStorage.setItem('token', res.data.token)
localStorage.setItem('username', res.data.username)
localStorage.setItem('role', res.data.role)
}
return res
}
const logout = () => {
token.value = ''
username.value = ''
role.value = ''
localStorage.removeItem('token')
localStorage.removeItem('username')
localStorage.removeItem('role')
}
const fetchUser = async () => {
try {
const res = await authApi.getMe()
if (res.data) {
username.value = res.data.username
role.value = res.data.role
}
} catch (e) {
logout()
}
}
return { token, username, role, login, logout, fetchUser }
})
@@ -1,240 +0,0 @@
<template>
<div style="display:flex;gap:0;height:100vh;overflow:hidden;">
<!-- 左侧请求配置 -->
<div style="width:360px;flex-shrink:0;overflow-y:auto;border-right:1px solid #f0f0f0;padding:20px;background:#fafafa;">
<div style="font-size:16px;font-weight:600;margin-bottom:20px;color:#333;">请求配置</div>
<n-form label-placement="top" size="small">
<n-form-item label="SiteKey">
<n-select v-model:value="cfg.siteKey" :options="siteOptions" placeholder="选择站点" filterable />
</n-form-item>
<n-form-item label="验证码类型">
<n-select v-model:value="cfg.type" :options="typeOptions" placeholder="自动选择" clearable />
</n-form-item>
<n-form-item label="场景">
<n-input v-model:value="cfg.scene" placeholder="default" />
</n-form-item>
<n-form-item label="服务地址">
<n-input v-model:value="cfg.serverUrl" />
</n-form-item>
<n-form-item label="样式模式">
<n-radio-group v-model:value="cfg.styleMode" size="small">
<n-radio-button value="light">浅色</n-radio-button>
<n-radio-button value="dark">深色</n-radio-button>
</n-radio-group>
</n-form-item>
</n-form>
<n-button type="primary" block @click="initCaptcha" :disabled="!cfg.siteKey" style="margin-top:8px;">
初始化验证码
</n-button>
<!-- 请求日志 -->
<div style="margin-top:20px;">
<div style="font-size:13px;font-weight:600;margin-bottom:8px;color:#666;">请求日志</div>
<div ref="logContainer" style="height:200px;overflow-y:auto;font-family:monospace;font-size:11px;background:#fff;border:1px solid #e8e8e8;border-radius:6px;padding:8px;">
<div v-for="(log, i) in logs" :key="i" :style="{color: logColor(log.type), marginBottom:'2px', lineHeight:'18px'}">
<span style="color:#bbb;">[{{ log.time }}]</span> {{ log.msg }}
</div>
<div v-if="logs.length === 0" style="color:#ccc;">等待操作...</div>
</div>
<n-button size="tiny" @click="logs = []" style="margin-top:4px;">清空</n-button>
</div>
<!-- 验证结果 -->
<div style="margin-top:16px;">
<div style="font-size:13px;font-weight:600;margin-bottom:8px;color:#666;">验证结果</div>
<div v-if="!verifyResult" style="color:#ccc;font-size:12px;">暂无结果</div>
<div v-else>
<n-tag :type="verifyResult.success ? 'success' : 'error'" size="small" style="margin-bottom:6px;">
{{ verifyResult.success ? '验证成功' : '验证失败' }}
</n-tag>
<div v-if="verifyResult.token" style="word-break:break-all;font-size:11px;color:#888;background:#f6ffed;border:1px solid #b7eb8f;border-radius:4px;padding:6px;">
<div style="font-weight:600;color:#52c41a;margin-bottom:2px;">verifyToken</div>
{{ verifyResult.token }}
</div>
<div v-if="verifyResult.error" style="font-size:11px;color:#ff4d4f;">{{ verifyResult.error }}</div>
</div>
</div>
</div>
<!-- 右侧验证码展示区 -->
<div style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;"
:style="rightBgStyle">
<!-- Logo + 标题 -->
<div style="display:flex;align-items:center;gap:10px;margin-bottom:24px;">
<img src="/logo/logo.svg" style="height:36px;" />
<span :style="{color: cfg.styleMode === 'dark' ? '#aaa' : '#999', fontSize:'13px'}">
{{ cfg.styleMode === 'dark' ? '深色模式 - 行为验证码' : '浅色模式 - 行为验证码' }}
</span>
</div>
<!-- 验证码触发区 -->
<div id="captcha-trigger-zone" style="width:340px;"></div>
<!-- 验证码弹窗容器 -->
<div id="captcha-modal-zone"></div>
<!-- 状态提示 -->
<div v-if="statusMsg" style="margin-top:16px;width:340px;">
<div :style="statusBarStyle">{{ statusMsg }}</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, nextTick, computed, watch } from 'vue'
import { NSelect, NInput, NButton, NForm, NFormItem, NTag, NRadioGroup, NRadioButton } from 'naive-ui'
import axios from 'axios'
const cfg = reactive({
siteKey: '',
type: null,
scene: 'default',
serverUrl: 'http://localhost:18200/api',
styleMode: 'light',
})
const siteOptions = ref([])
const typeOptions = [
{ label: 'SLIDER 滑块', value: 'SLIDER' },
{ label: 'SLIDER_V2 增强滑块', value: 'SLIDER_V2' },
{ label: 'ROTATE 旋转', value: 'ROTATE' },
{ label: 'CONCAT 滑动还原', value: 'CONCAT' },
{ label: 'WORD_IMAGE_CLICK 文字点选', value: 'WORD_IMAGE_CLICK' },
{ label: 'ICON_CLICK 图标点选', value: 'ICON_CLICK' },
{ label: 'WORD_ORDER_CLICK 语序点选', value: 'WORD_ORDER_CLICK' },
{ label: 'CURVE_SLIDER 曲线滑块', value: 'CURVE_SLIDER' },
{ label: 'ANGLE 角度', value: 'ANGLE' },
{ label: 'SCRATCH 刮刮乐', value: 'SCRATCH' },
{ label: 'JIGSAW 乱序拼图', value: 'JIGSAW' },
{ label: 'CURVE_DRAW 曲线绘制', value: 'CURVE_DRAW' },
]
const logs = ref([])
const verifyResult = ref(null)
const statusMsg = ref('')
const logContainer = ref(null)
const captchaInstance = ref(null)
const rightBgStyle = ref({})
const statusBarStyle = ref({})
watch(() => cfg.styleMode, (v) => {
if (v === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark')
rightBgStyle.value = { background: 'linear-gradient(135deg, #0f0c29, #302b63, #24243e)' }
statusBarStyle.value = { color: '#888', fontSize: '12px', textAlign: 'center' }
} else {
document.documentElement.removeAttribute('data-theme')
rightBgStyle.value = { background: '#f5f5f5' }
statusBarStyle.value = { color: '#999', fontSize: '12px', textAlign: 'center' }
}
}, { immediate: true })
function log(type, msg) {
const time = new Date().toLocaleTimeString('zh-CN')
logs.value.push({ type, msg, time })
if (logs.value.length > 80) logs.value.shift()
nextTick(() => {
if (logContainer.value) logContainer.value.scrollTop = logContainer.value.scrollHeight
})
}
function logColor(type) {
if (type === 'req') return '#1890ff'
if (type === 'res') return '#52c41a'
if (type === 'err') return '#ff4d4f'
return '#999'
}
async function loadSites() {
try {
const resp = await axios.get('/api/admin/sites')
if (resp.data?.code === 200) {
const content = resp.data.data?.content || []
siteOptions.value = content.map(s => ({
label: s.name + ' (' + s.siteKey + ')',
value: s.siteKey,
}))
if (content.length === 1) cfg.siteKey = content[0].siteKey
}
} catch (e) {
log('err', '加载站点失败: ' + e.message)
}
}
async function initCaptcha() {
if (!cfg.siteKey) return
verifyResult.value = null
statusMsg.value = '加载中...'
log('info', '初始化验证码...')
if (captchaInstance.value) {
captchaInstance.value.destroy()
captchaInstance.value = null
}
await nextTick()
const triggerEl = document.getElementById('captcha-trigger-zone')
if (triggerEl) triggerEl.innerHTML = ''
const modalEl = document.getElementById('captcha-modal-zone')
if (modalEl) modalEl.innerHTML = ''
const isDark = cfg.styleMode === 'dark'
try {
if (!window.TACaptcha) {
const script = document.createElement('script')
script.src = '/sdk/captcha.js'
document.head.appendChild(script)
await new Promise((resolve, reject) => {
script.onload = resolve
script.onerror = () => reject(new Error('加载 captcha.js 失败'))
})
}
const trigger = document.createElement('div')
trigger.id = 'test-captcha-btn'
trigger.style.cssText = 'width:100%;'
triggerEl.appendChild(trigger)
log('req', 'POST ' + cfg.serverUrl + '/challenge/generate?type=' + (cfg.type || 'auto'))
log('info', 'X-Site-Key: ' + cfg.siteKey)
captchaInstance.value = window.TACaptcha.init({
elId: 'test-captcha-btn',
siteKey: cfg.siteKey,
type: cfg.type,
scene: cfg.scene,
serverUrl: cfg.serverUrl,
logoUrl: '/logo/logo.svg',
theme: isDark ? 'dark' : 'light',
mode: 'click',
onSuccess: (result) => {
log('res', '验证成功! token: ' + result.data.verifyToken.substring(0, 40) + '...')
verifyResult.value = { success: true, token: result.data.verifyToken }
statusMsg.value = '验证成功'
},
onFail: (err) => {
log('err', '验证失败: ' + JSON.stringify(err))
verifyResult.value = { success: false, error: err?.msg || '基础校验失败' }
statusMsg.value = '验证失败'
},
onOpen: () => log('info', '弹窗打开'),
onClose: () => log('info', '弹窗关闭'),
})
log('res', '初始化完成')
statusMsg.value = '请点击上方按钮进行验证'
} catch (e) {
log('err', '初始化失败: ' + e.message)
statusMsg.value = '初始化失败: ' + e.message
}
}
onMounted(loadSites)
</script>
@@ -1,49 +0,0 @@
<template>
<n-space vertical :size="24">
<n-h2>仪表盘</n-h2>
<n-grid :cols="4" :x-gap="16" :y-gap="16">
<n-gi><n-statistic label="请求总数" :value="stats.total" /></n-gi>
<n-gi><n-statistic label="通过总数" :value="stats.success" /></n-gi>
<n-gi><n-statistic label="失败总数" :value="stats.fail" /></n-gi>
<n-gi><n-statistic label="总通过率" :value="stats.passRate" /></n-gi>
</n-grid>
<n-h3>验证码类型</n-h3>
<n-grid :cols="4" :x-gap="12" :y-gap="12">
<n-gi v-for="t in types" :key="t.key">
<n-card size="small" :title="t.label">
<template #header-extra><n-tag size="small">{{ t.key }}</n-tag></template>
已注册
</n-card>
</n-gi>
</n-grid>
</n-space>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { NSpace, NH2, NH3, NGrid, NGi, NStatistic, NCard, NTag } from 'naive-ui'
import axios from 'axios'
const stats = ref({ total: 0, success: 0, fail: 0, passRate: '0%' })
const types = [
{ key: 'SLIDER', label: '滑块验证' },
{ key: 'SLIDER_V2', label: '增强滑块' },
{ key: 'ROTATE', label: '旋转验证' },
{ key: 'CONCAT', label: '滑动还原' },
{ key: 'WORD_IMAGE_CLICK', label: '文字点选' },
{ key: 'ICON_CLICK', label: '图标点选' },
{ key: 'WORD_ORDER_CLICK', label: '语序点选' },
{ key: 'CURVE_SLIDER', label: '曲线滑块' },
{ key: 'ANGLE', label: '角度验证' },
{ key: 'SCRATCH', label: '刮刮乐' },
{ key: 'JIGSAW', label: '乱序拼图' },
{ key: 'CURVE_DRAW', label: '曲线绘制' },
]
onMounted(async () => {
try {
const resp = await axios.get('/api/admin/stats?days=7')
if (resp.data?.code === 200) stats.value = resp.data.data
} catch {}
})
</script>
@@ -1,152 +0,0 @@
<template>
<n-space vertical :size="20">
<n-h2>站点管理
<template #suffix>
<n-button type="primary" @click="showCreate = true">创建站点</n-button>
</template>
</n-h2>
<n-data-table :columns="columns" :data="sites" :pagination="{ pageSize: 20 }" :bordered="false" />
<n-modal v-model:show="showCreate" preset="dialog" title="创建站点" positive-text="创建" @positive-click="createSite">
<n-form>
<n-form-item label="站点名称"><n-input v-model:value="form.name" /></n-form-item>
<n-form-item label="域名"><n-input v-model:value="form.domain" placeholder="lovehome.safina520.cn" /></n-form-item>
<n-form-item label="验证码类型">
<n-checkbox-group v-model:value="form.captchaTypes">
<n-space>
<n-checkbox v-for="t in allTypes" :key="t.value" :value="t.value" :label="t.label" />
</n-space>
</n-checkbox-group>
</n-form-item>
</n-form>
</n-modal>
<n-modal v-model:show="showDetail" preset="dialog" title="站点详情" :show-action="false" style="width:600px">
<n-descriptions v-if="detailSite" bordered :column="1" label-placement="left" size="small">
<n-descriptions-item label="ID">{{ detailSite.id }}</n-descriptions-item>
<n-descriptions-item label="名称">{{ detailSite.name }}</n-descriptions-item>
<n-descriptions-item label="域名">{{ detailSite.domain }}</n-descriptions-item>
<n-descriptions-item label="SiteKey">
<n-tag size="small" :bordered="false">{{ detailSite.siteKey }}</n-tag>
</n-descriptions-item>
<n-descriptions-item label="SecretKey">
<n-tag size="small" :bordered="false">{{ detailSite.secretKey }}</n-tag>
</n-descriptions-item>
<n-descriptions-item label="验证等级">{{ detailSite.verifyLevel }}</n-descriptions-item>
<n-descriptions-item label="QPS">{{ detailSite.qps }}</n-descriptions-item>
<n-descriptions-item label="日限制">{{ detailSite.dailyLimit }}</n-descriptions-item>
<n-descriptions-item label="验证码类型">
<n-space>
<n-tag v-for="t in (detailSite.captchaTypes || [])" :key="t" size="small">{{ t }}</n-tag>
</n-space>
</n-descriptions-item>
<n-descriptions-item label="状态">
<n-tag :type="detailSite.isEnabled ? 'success' : 'error'" size="small">{{ detailSite.isEnabled ? '启用' : '禁用' }}</n-tag>
</n-descriptions-item>
<n-descriptions-item label="轨迹验证">{{ detailSite.trackValidationEnabled ? '开启' : '关闭' }}</n-descriptions-item>
<n-descriptions-item label="对抗扰动">{{ detailSite.obfuscationEnabled ? '开启' : '关闭' }}</n-descriptions-item>
<n-descriptions-item label="端到端加密">{{ detailSite.encryptionEnabled ? '开启' : '关闭' }}</n-descriptions-item>
<n-descriptions-item label="创建时间">{{ formatTime(detailSite.createdAt) }}</n-descriptions-item>
</n-descriptions>
</n-modal>
</n-space>
</template>
<script setup>
import { ref, onMounted, h } from 'vue'
import { NSpace, NH2, NButton, NDataTable, NModal, NForm, NFormItem, NInput, NTag, NCheckboxGroup, NCheckbox, NDescriptions, NDescriptionsItem } from 'naive-ui'
import axios from 'axios'
const sites = ref([])
const showCreate = ref(false)
const showDetail = ref(false)
const detailSite = ref(null)
const form = ref({ name: '', domain: '', captchaTypes: ['SLIDER', 'ROTATE', 'ICON_CLICK'] })
const allTypes = [
{ value: 'SLIDER', label: '滑块验证' },
{ value: 'SLIDER_V2', label: '增强滑块' },
{ value: 'ROTATE', label: '旋转验证' },
{ value: 'CONCAT', label: '滑动还原' },
{ value: 'WORD_IMAGE_CLICK', label: '文字点选' },
{ value: 'ICON_CLICK', label: '图标点选' },
{ value: 'WORD_ORDER_CLICK', label: '语序点选' },
{ value: 'CURVE_SLIDER', label: '曲线滑块' },
{ value: 'ANGLE', label: '角度验证' },
{ value: 'SCRATCH', label: '刮刮乐' },
{ value: 'JIGSAW', label: '乱序拼图' },
{ value: 'CURVE_DRAW', label: '曲线绘制' },
]
const columns = [
{ title: 'ID', key: 'id', width: 60 },
{ title: '名称', key: 'name', width: 120 },
{ title: '域名', key: 'domain', width: 160 },
{
title: 'SiteKey', key: 'siteKey', width: 200,
render: (row) => h(NTag, { size: 'small', bordered: false }, () => row.siteKey)
},
{
title: '验证等级', key: 'verifyLevel', width: 90,
render: (row) => h(NTag, {
type: row.verifyLevel === 'HIGH' ? 'error' : row.verifyLevel === 'MEDIUM' ? 'warning' : 'info', size: 'small'
}, () => row.verifyLevel)
},
{
title: '状态', key: 'isEnabled', width: 70,
render: (row) => h(NTag, { type: row.isEnabled ? 'success' : 'error', size: 'small' }, () => row.isEnabled ? '启用' : '禁用')
},
{
title: '创建时间', key: 'createdAt', width: 160,
render: (row) => formatTime(row.createdAt)
},
{
title: '操作', key: 'actions', width: 200,
render: (row) => h('div', { style: 'display:flex;gap:8px;' }, [
h(NButton, { size: 'small', onClick: () => viewSite(row) }, () => '详情'),
h(NButton, { size: 'small', onClick: () => toggleSite(row) }, () => row.isEnabled ? '禁用' : '启用'),
h(NButton, { size: 'small', type: 'error', onClick: () => deleteSite(row.id) }, () => '删除'),
])
},
]
function formatTime(v) {
if (!v) return '-'
return new Date(v).toLocaleString('zh-CN')
}
async function loadSites() {
try {
const resp = await axios.get('/api/admin/sites')
if (resp.data?.code === 200) sites.value = resp.data.data?.content || []
} catch {}
}
async function createSite() {
await axios.post('/api/admin/sites', {
name: form.value.name,
domain: form.value.domain,
captchaTypes: form.value.captchaTypes,
})
form.value = { name: '', domain: '', captchaTypes: ['SLIDER', 'ROTATE', 'ICON_CLICK'] }
await loadSites()
}
function viewSite(row) {
detailSite.value = row
showDetail.value = true
}
async function toggleSite(row) {
row.isEnabled = !row.isEnabled
await axios.put(`/api/admin/sites/${row.id}`, row)
await loadSites()
}
async function deleteSite(id) {
await axios.delete(`/api/admin/sites/${id}`)
await loadSites()
}
onMounted(loadSites)
</script>
@@ -1,67 +0,0 @@
<template>
<n-space vertical :size="20">
<n-h2>统计监控</n-h2>
<n-grid :cols="4" :x-gap="16" :y-gap="16">
<n-gi><n-statistic label="请求总数" :value="stats.total" /></n-gi>
<n-gi><n-statistic label="通过总数" :value="stats.success" /></n-gi>
<n-gi><n-statistic label="失败总数" :value="stats.fail" /></n-gi>
<n-gi><n-statistic label="总通过率" :value="stats.passRate" /></n-gi>
</n-grid>
<n-h3>IP黑名单管理</n-h3>
<n-space>
<n-input v-model:value="banForm.ip" placeholder="IP地址" style="width:200px" />
<n-input v-model:value="banForm.reason" placeholder="封禁原因" style="width:200px" />
<n-button type="error" @click="banIp">封禁IP</n-button>
</n-space>
<n-data-table :columns="ipColumns" :data="bannedIps" :bordered="false" />
</n-space>
</template>
<script setup>
import { ref, onMounted, h } from 'vue'
import { NSpace, NH2, NH3, NGrid, NGi, NStatistic, NDataTable, NButton, NInput, NTag } from 'naive-ui'
import axios from 'axios'
const stats = ref({ total: 0, success: 0, fail: 0, passRate: '0%' })
const bannedIps = ref([])
const banForm = ref({ ip: '', reason: '' })
const ipColumns = [
{ title: 'IP', key: 'ip' },
{ title: '原因', key: 'reason' },
{ title: '解封时间', key: 'banUntil', render: (row) => row.banUntil ? new Date(row.banUntil).toLocaleString('zh-CN') : '永久' },
{
title: '操作', key: 'actions',
render: (row) => h(NButton, { size: 'small', type: 'warning', onClick: () => unbanIp(row.ip) }, () => '解封')
},
]
async function loadStats() {
try {
const resp = await axios.get('/api/admin/stats?days=7')
if (resp.data?.code === 200) stats.value = resp.data.data
} catch {}
}
async function banIp() {
if (!banForm.value.ip) return
await axios.post('/api/admin/ip-blacklist', {
ip: banForm.value.ip,
reason: banForm.value.reason,
durationMs: 3600000,
})
banForm.value = { ip: '', reason: '' }
await loadBannedIps()
}
async function unbanIp(ip) {
await axios.delete(`/api/admin/ip-blacklist/${ip}`)
await loadBannedIps()
}
async function loadBannedIps() {
bannedIps.value = []
}
onMounted(() => { loadStats(); loadBannedIps() })
</script>
@@ -0,0 +1,78 @@
<template>
<div class="dashboard">
<el-row :gutter="20">
<el-col :span="6">
<el-card shadow="hover">
<template #header>总请求量</template>
<div class="stat-value">{{ stats.total || 0 }}</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<template #header>成功次数</template>
<div class="stat-value" style="color: #67c23a">{{ stats.success || 0 }}</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<template #header>失败次数</template>
<div class="stat-value" style="color: #f56c6c">{{ stats.fail || 0 }}</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<template #header>成功率</template>
<div class="stat-value" style="color: #409eff">{{ stats.passRate || '0%' }}</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" style="margin-top: 20px">
<el-col :span="12">
<el-card>
<template #header>站点数量</template>
<div class="stat-value">{{ siteCount }}</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card>
<template #header>活跃套餐</template>
<div class="stat-value">{{ planCount }}</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { statsApi, siteApi, planApi } from '../../api'
const stats = ref({})
const siteCount = ref(0)
const planCount = ref(0)
onMounted(async () => {
try {
const [statsRes, sitesRes, plansRes] = await Promise.all([
statsApi.get({ days: 7 }),
siteApi.list({ page: 0, size: 1 }),
planApi.list()
])
stats.value = statsRes.data || {}
siteCount.value = sitesRes.data?.totalElements || 0
planCount.value = plansRes.data?.length || 0
} catch (e) {
console.error('Failed to load dashboard data', e)
}
})
</script>
<style scoped>
.stat-value {
font-size: 32px;
font-weight: bold;
text-align: center;
padding: 20px 0;
}
</style>
@@ -0,0 +1,83 @@
<template>
<div class="ip-blacklist">
<el-card>
<template #header>
<div class="card-header">
<span>IP黑名单</span>
<el-button type="primary" @click="showDialog">封禁IP</el-button>
</div>
</template>
<el-table :data="blacklist" v-loading="loading" border>
<el-table-column prop="ip" label="IP地址" />
<el-table-column prop="reason" label="原因" />
<el-table-column prop="banUntil" label="封禁至" />
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button size="small" type="danger" @click="handleUnban(row.ip)">解封</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="dialogVisible" title="封禁IP" width="400px">
<el-form :model="form" label-width="80px">
<el-form-item label="IP地址">
<el-input v-model="form.ip" placeholder="例如: 192.168.1.1" />
</el-form-item>
<el-form-item label="原因">
<el-input v-model="form.reason" />
</el-form-item>
<el-form-item label="封禁时长">
<el-select v-model="form.durationMs">
<el-option label="1小时" :value="3600000" />
<el-option label="24小时" :value="86400000" />
<el-option label="7天" :value="604800000" />
<el-option label="永久" :value="31536000000" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleBan">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ipBlacklistApi } from '../../api'
import { ElMessage } from 'element-plus'
const blacklist = ref([])
const loading = ref(false)
const dialogVisible = ref(false)
const form = reactive({ ip: '', reason: '', durationMs: 3600000 })
const showDialog = () => {
form.ip = ''
form.reason = ''
form.durationMs = 3600000
dialogVisible.value = true
}
const handleBan = async () => {
await ipBlacklistApi.ban(form)
ElMessage.success('封禁成功')
dialogVisible.value = false
}
const handleUnban = async (ip) => {
await ipBlacklistApi.unban(ip)
ElMessage.success('解封成功')
}
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
@@ -0,0 +1,88 @@
<template>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<h2>TianAI Captcha 管理平台</h2>
</div>
<el-form ref="formRef" :model="form" :rules="rules" label-width="0">
<el-form-item prop="username">
<el-input v-model="form.username" placeholder="用户名" prefix-icon="User" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" type="password" placeholder="密码" prefix-icon="Lock" show-password />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="handleLogin" style="width: 100%">
登录
</el-button>
</el-form-item>
</el-form>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '../../store/user'
import { ElMessage } from 'element-plus'
const router = useRouter()
const userStore = useUserStore()
const formRef = ref(null)
const loading = ref(false)
const form = reactive({
username: '',
password: ''
})
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
const handleLogin = async () => {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
await userStore.login(form)
ElMessage.success('登录成功')
router.push('/')
} catch (e) {
ElMessage.error(e.msg || '登录失败')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-container {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 400px;
padding: 40px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.login-header {
text-align: center;
margin-bottom: 30px;
}
.login-header h2 {
color: #303133;
margin: 0;
}
</style>
@@ -0,0 +1,81 @@
<template>
<div class="logs">
<el-card>
<template #header>验证码日志</template>
<el-form :inline="true" :model="filters" style="margin-bottom: 20px">
<el-form-item label="验证码类型">
<el-select v-model="filters.captchaType" clearable placeholder="全部">
<el-option label="滑块验证" value="SLIDER" />
<el-option label="旋转验证" value="ROTATE" />
<el-option label="文字点选" value="WORD_IMAGE_CLICK" />
<el-option label="图标点选" value="ICON_CLICK" />
</el-select>
</el-form-item>
<el-form-item label="结果">
<el-select v-model="filters.isPass" clearable placeholder="全部">
<el-option label="成功" :value="true" />
<el-option label="失败" :value="false" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="loadLogs">查询</el-button>
</el-form-item>
</el-form>
<el-table :data="logs" v-loading="loading" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="captchaType" label="类型" width="120" />
<el-table-column prop="ip" label="IP地址" width="140" />
<el-table-column prop="isPass" label="结果" width="80">
<template #default="{ row }">
<el-tag :type="row.isPass ? 'success' : 'danger'">
{{ row.isPass ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="costTime" label="耗时(ms)" width="100" />
<el-table-column prop="scene" label="场景" width="100" />
<el-table-column prop="createdAt" label="时间">
<template #default="{ row }">
{{ formatTime(row.createdAt) }}
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="page"
:page-size="20"
:total="total"
layout="total, prev, pager, next"
@current-change="loadLogs"
style="margin-top: 20px; justify-content: flex-end"
/>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
const logs = ref([])
const loading = ref(false)
const page = ref(1)
const total = ref(0)
const filters = reactive({ captchaType: '', isPass: null })
onMounted(() => loadLogs())
const loadLogs = async () => {
loading.value = true
try {
// 模拟数据
logs.value = []
total.value = 0
} finally {
loading.value = false
}
}
const formatTime = (t) => t ? new Date(t).toLocaleString() : '-'
</script>
@@ -0,0 +1,128 @@
<template>
<div class="plans">
<el-card>
<template #header>
<div class="card-header">
<span>套餐管理</span>
<el-button type="primary" @click="showDialog()">新增套餐</el-button>
</div>
</template>
<el-table :data="plans" v-loading="loading" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="套餐名称" />
<el-table-column prop="description" label="描述" />
<el-table-column prop="dailyQuota" label="每日配额" />
<el-table-column prop="qpsLimit" label="QPS限制" />
<el-table-column prop="siteLimit" label="站点限制" />
<el-table-column prop="isActive" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.isActive ? 'success' : 'danger'">
{{ row.isActive ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button size="small" @click="showDialog(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="dialogVisible" :title="editingPlan ? '编辑套餐' : '新增套餐'" width="500px">
<el-form :model="form" label-width="100px">
<el-form-item label="套餐名称">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" />
</el-form-item>
<el-form-item label="每日配额">
<el-input-number v-model="form.dailyQuota" :min="1" />
</el-form-item>
<el-form-item label="QPS限制">
<el-input-number v-model="form.qpsLimit" :min="1" />
</el-form-item>
<el-form-item label="站点限制">
<el-input-number v-model="form.siteLimit" :min="1" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { planApi } from '../../api'
import { ElMessage, ElMessageBox } from 'element-plus'
const plans = ref([])
const loading = ref(false)
const dialogVisible = ref(false)
const editingPlan = ref(null)
const form = reactive({
name: '', description: '', dailyQuota: 500, qpsLimit: 10, siteLimit: 5
})
onMounted(() => loadPlans())
const loadPlans = async () => {
loading.value = true
try {
const res = await planApi.list()
plans.value = res.data || []
} finally {
loading.value = false
}
}
const showDialog = (plan) => {
editingPlan.value = plan
if (plan) {
Object.assign(form, plan)
} else {
form.name = ''
form.description = ''
form.dailyQuota = 500
form.qpsLimit = 10
form.siteLimit = 5
}
dialogVisible.value = true
}
const handleSave = async () => {
try {
if (editingPlan.value) {
await planApi.update(editingPlan.value.id, { ...form, id: editingPlan.value.id })
} else {
await planApi.create(form)
}
ElMessage.success('保存成功')
dialogVisible.value = false
loadPlans()
} catch (e) {
ElMessage.error('保存失败')
}
}
const handleDelete = async (id) => {
await ElMessageBox.confirm('确定删除该套餐?', '提示', { type: 'warning' })
await planApi.delete(id)
ElMessage.success('删除成功')
loadPlans()
}
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
@@ -0,0 +1,32 @@
<template>
<div class="settings">
<el-card>
<template #header>系统设置</template>
<el-form :model="settings" label-width="120px" style="max-width: 600px">
<el-form-item label="验证码前缀">
<el-input v-model="settings.prefix" disabled />
</el-form-item>
<el-form-item label="默认过期时间">
<el-input v-model="settings.expireDefault" disabled />
</el-form-item>
<el-form-item label="本地缓存">
<el-switch v-model="settings.localCacheEnabled" disabled />
</el-form-item>
<el-form-item label="缓存大小">
<el-input v-model="settings.localCacheSize" disabled />
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup>
import { reactive } from 'vue'
const settings = reactive({
prefix: 'captcha',
expireDefault: '120000ms',
localCacheEnabled: true,
localCacheSize: 20
})
</script>
@@ -0,0 +1,134 @@
<template>
<div class="sites">
<el-card>
<template #header>
<div class="card-header">
<span>站点管理</span>
<el-button type="primary" @click="showDialog()">新增站点</el-button>
</div>
</template>
<el-table :data="sites" v-loading="loading" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="站点名称" />
<el-table-column prop="domain" label="域名" />
<el-table-column prop="siteKey" label="Site Key">
<template #default="{ row }">
<el-text truncated>{{ row.siteKey }}</el-text>
</template>
</el-table-column>
<el-table-column prop="isEnabled" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.isEnabled ? 'success' : 'danger'">
{{ row.isEnabled ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button size="small" @click="showDialog(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="page"
:page-size="20"
:total="total"
layout="total, prev, pager, next"
@current-change="loadSites"
style="margin-top: 20px; justify-content: flex-end"
/>
</el-card>
<el-dialog v-model="dialogVisible" :title="editingSite ? '编辑站点' : '新增站点'" width="500px">
<el-form :model="form" label-width="100px">
<el-form-item label="站点名称">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="域名">
<el-input v-model="form.domain" />
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="form.isEnabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { siteApi } from '../../api'
import { ElMessage, ElMessageBox } from 'element-plus'
const sites = ref([])
const loading = ref(false)
const page = ref(1)
const total = ref(0)
const dialogVisible = ref(false)
const editingSite = ref(null)
const form = reactive({ name: '', domain: '', isEnabled: true })
onMounted(() => loadSites())
const loadSites = async () => {
loading.value = true
try {
const res = await siteApi.list({ page: page.value - 1, size: 20 })
sites.value = res.data?.content || []
total.value = res.data?.totalElements || 0
} finally {
loading.value = false
}
}
const showDialog = (site) => {
editingSite.value = site
if (site) {
form.name = site.name
form.domain = site.domain
form.isEnabled = site.isEnabled
} else {
form.name = ''
form.domain = ''
form.isEnabled = true
}
dialogVisible.value = true
}
const handleSave = async () => {
try {
if (editingSite.value) {
await siteApi.update(editingSite.value.id, { ...form, id: editingSite.value.id })
} else {
await siteApi.create(form)
}
ElMessage.success('保存成功')
dialogVisible.value = false
loadSites()
} catch (e) {
ElMessage.error('保存失败')
}
}
const handleDelete = async (id) => {
await ElMessageBox.confirm('确定删除该站点?', '提示', { type: 'warning' })
await siteApi.delete(id)
ElMessage.success('删除成功')
loadSites()
}
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>