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

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

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

Phase 5: 部署和测试
- docker-compose.yml 5服务编排
- application-prod.yml 外部化配置
- CaptchaApiIntegrationTest 集成测试
- 前端 Dockerfile + nginx.conf
This commit is contained in:
abcv7
2026-08-26 13:44:08 +08:00
parent d1cd371061
commit 1164b51c64
67 changed files with 5510 additions and 829 deletions
@@ -1,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>