feat: 增强版验证码全套实现 — 16种类型生成器/加密/风控/站点管理/平台后端/前端SDK/UI
- 核心新增: ICON_CLICK(PNG图标方案,预渲染资源)/SCRATCH/JIGSAW/CURVE_SLIDER(V1-V3)/ANGLE/CURVE_DRAW/WORD_ORDER_CLICK/PROOF_OF_WORK 等生成器 - 图标点选: classpath PNG 加载替代运行时字体渲染(Linux容器无emoji字体),提示条图作为 templateImage 返回前端 - 新增模块: crypto(AES+RSA)/obfuscator(背景乱序/噪声/扭曲)/risk(风控/IP黑名单/限流)/site(站点管理)/ml(轨迹规则引擎) - 平台后端: 站点管理/验证码API(generate/verify/secondary-verify)/统计/ML轨迹学习 - 前端SDK: TPCaptcha兼容,支持全部新型号渲染与交互 - 工具: tools/icon-render 图标预渲染工具
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
<template>
|
||||
<div style="display:flex;gap:0;height:100vh;overflow:hidden;">
|
||||
<!-- 左侧:请求配置 -->
|
||||
<div style="width:360px;flex-shrink:0;overflow-y:auto;border-right:1px solid #f0f0f0;padding:20px;background:#fafafa;">
|
||||
<div style="font-size:16px;font-weight:600;margin-bottom:20px;color:#333;">请求配置</div>
|
||||
<n-form label-placement="top" size="small">
|
||||
<n-form-item label="SiteKey">
|
||||
<n-select v-model:value="cfg.siteKey" :options="siteOptions" placeholder="选择站点" filterable />
|
||||
</n-form-item>
|
||||
<n-form-item label="验证码类型">
|
||||
<n-select v-model:value="cfg.type" :options="typeOptions" placeholder="自动选择" clearable />
|
||||
</n-form-item>
|
||||
<n-form-item label="场景">
|
||||
<n-input v-model:value="cfg.scene" placeholder="default" />
|
||||
</n-form-item>
|
||||
<n-form-item label="服务地址">
|
||||
<n-input v-model:value="cfg.serverUrl" />
|
||||
</n-form-item>
|
||||
<n-form-item label="样式模式">
|
||||
<n-radio-group v-model:value="cfg.styleMode" size="small">
|
||||
<n-radio-button value="light">浅色</n-radio-button>
|
||||
<n-radio-button value="dark">深色</n-radio-button>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-button type="primary" block @click="initCaptcha" :disabled="!cfg.siteKey" style="margin-top:8px;">
|
||||
初始化验证码
|
||||
</n-button>
|
||||
|
||||
<!-- 请求日志 -->
|
||||
<div style="margin-top:20px;">
|
||||
<div style="font-size:13px;font-weight:600;margin-bottom:8px;color:#666;">请求日志</div>
|
||||
<div ref="logContainer" style="height:200px;overflow-y:auto;font-family:monospace;font-size:11px;background:#fff;border:1px solid #e8e8e8;border-radius:6px;padding:8px;">
|
||||
<div v-for="(log, i) in logs" :key="i" :style="{color: logColor(log.type), marginBottom:'2px', lineHeight:'18px'}">
|
||||
<span style="color:#bbb;">[{{ log.time }}]</span> {{ log.msg }}
|
||||
</div>
|
||||
<div v-if="logs.length === 0" style="color:#ccc;">等待操作...</div>
|
||||
</div>
|
||||
<n-button size="tiny" @click="logs = []" style="margin-top:4px;">清空</n-button>
|
||||
</div>
|
||||
|
||||
<!-- 验证结果 -->
|
||||
<div style="margin-top:16px;">
|
||||
<div style="font-size:13px;font-weight:600;margin-bottom:8px;color:#666;">验证结果</div>
|
||||
<div v-if="!verifyResult" style="color:#ccc;font-size:12px;">暂无结果</div>
|
||||
<div v-else>
|
||||
<n-tag :type="verifyResult.success ? 'success' : 'error'" size="small" style="margin-bottom:6px;">
|
||||
{{ verifyResult.success ? '验证成功' : '验证失败' }}
|
||||
</n-tag>
|
||||
<div v-if="verifyResult.token" style="word-break:break-all;font-size:11px;color:#888;background:#f6ffed;border:1px solid #b7eb8f;border-radius:4px;padding:6px;">
|
||||
<div style="font-weight:600;color:#52c41a;margin-bottom:2px;">verifyToken</div>
|
||||
{{ verifyResult.token }}
|
||||
</div>
|
||||
<div v-if="verifyResult.error" style="font-size:11px;color:#ff4d4f;">{{ verifyResult.error }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:验证码展示区 -->
|
||||
<div style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;"
|
||||
:style="rightBgStyle">
|
||||
|
||||
<!-- Logo + 标题 -->
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:24px;">
|
||||
<img src="/logo/logo.svg" style="height:36px;" />
|
||||
<span :style="{color: cfg.styleMode === 'dark' ? '#aaa' : '#999', fontSize:'13px'}">
|
||||
{{ cfg.styleMode === 'dark' ? '深色模式 - 行为验证码' : '浅色模式 - 行为验证码' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 验证码触发区 -->
|
||||
<div id="captcha-trigger-zone" style="width:340px;"></div>
|
||||
|
||||
<!-- 验证码弹窗容器 -->
|
||||
<div id="captcha-modal-zone"></div>
|
||||
|
||||
<!-- 状态提示 -->
|
||||
<div v-if="statusMsg" style="margin-top:16px;width:340px;">
|
||||
<div :style="statusBarStyle">{{ statusMsg }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, nextTick, computed, watch } from 'vue'
|
||||
import { NSelect, NInput, NButton, NForm, NFormItem, NTag, NRadioGroup, NRadioButton } from 'naive-ui'
|
||||
import axios from 'axios'
|
||||
|
||||
const cfg = reactive({
|
||||
siteKey: '',
|
||||
type: null,
|
||||
scene: 'default',
|
||||
serverUrl: 'http://localhost:18200/api',
|
||||
styleMode: 'light',
|
||||
})
|
||||
|
||||
const siteOptions = ref([])
|
||||
const typeOptions = [
|
||||
{ label: 'SLIDER 滑块', value: 'SLIDER' },
|
||||
{ label: 'SLIDER_V2 增强滑块', value: 'SLIDER_V2' },
|
||||
{ label: 'ROTATE 旋转', value: 'ROTATE' },
|
||||
{ label: 'CONCAT 滑动还原', value: 'CONCAT' },
|
||||
{ label: 'WORD_IMAGE_CLICK 文字点选', value: 'WORD_IMAGE_CLICK' },
|
||||
{ label: 'ICON_CLICK 图标点选', value: 'ICON_CLICK' },
|
||||
{ label: 'WORD_ORDER_CLICK 语序点选', value: 'WORD_ORDER_CLICK' },
|
||||
{ label: 'CURVE_SLIDER 曲线滑块', value: 'CURVE_SLIDER' },
|
||||
{ label: 'ANGLE 角度', value: 'ANGLE' },
|
||||
{ label: 'SCRATCH 刮刮乐', value: 'SCRATCH' },
|
||||
{ label: 'JIGSAW 乱序拼图', value: 'JIGSAW' },
|
||||
{ label: 'CURVE_DRAW 曲线绘制', value: 'CURVE_DRAW' },
|
||||
]
|
||||
|
||||
const logs = ref([])
|
||||
const verifyResult = ref(null)
|
||||
const statusMsg = ref('')
|
||||
const logContainer = ref(null)
|
||||
const captchaInstance = ref(null)
|
||||
|
||||
const rightBgStyle = ref({})
|
||||
const statusBarStyle = ref({})
|
||||
|
||||
watch(() => cfg.styleMode, (v) => {
|
||||
if (v === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark')
|
||||
rightBgStyle.value = { background: 'linear-gradient(135deg, #0f0c29, #302b63, #24243e)' }
|
||||
statusBarStyle.value = { color: '#888', fontSize: '12px', textAlign: 'center' }
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
rightBgStyle.value = { background: '#f5f5f5' }
|
||||
statusBarStyle.value = { color: '#999', fontSize: '12px', textAlign: 'center' }
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
function log(type, msg) {
|
||||
const time = new Date().toLocaleTimeString('zh-CN')
|
||||
logs.value.push({ type, msg, time })
|
||||
if (logs.value.length > 80) logs.value.shift()
|
||||
nextTick(() => {
|
||||
if (logContainer.value) logContainer.value.scrollTop = logContainer.value.scrollHeight
|
||||
})
|
||||
}
|
||||
|
||||
function logColor(type) {
|
||||
if (type === 'req') return '#1890ff'
|
||||
if (type === 'res') return '#52c41a'
|
||||
if (type === 'err') return '#ff4d4f'
|
||||
return '#999'
|
||||
}
|
||||
|
||||
async function loadSites() {
|
||||
try {
|
||||
const resp = await axios.get('/api/admin/sites')
|
||||
if (resp.data?.code === 200) {
|
||||
const content = resp.data.data?.content || []
|
||||
siteOptions.value = content.map(s => ({
|
||||
label: s.name + ' (' + s.siteKey + ')',
|
||||
value: s.siteKey,
|
||||
}))
|
||||
if (content.length === 1) cfg.siteKey = content[0].siteKey
|
||||
}
|
||||
} catch (e) {
|
||||
log('err', '加载站点失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function initCaptcha() {
|
||||
if (!cfg.siteKey) return
|
||||
verifyResult.value = null
|
||||
statusMsg.value = '加载中...'
|
||||
log('info', '初始化验证码...')
|
||||
|
||||
if (captchaInstance.value) {
|
||||
captchaInstance.value.destroy()
|
||||
captchaInstance.value = null
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
const triggerEl = document.getElementById('captcha-trigger-zone')
|
||||
if (triggerEl) triggerEl.innerHTML = ''
|
||||
|
||||
const modalEl = document.getElementById('captcha-modal-zone')
|
||||
if (modalEl) modalEl.innerHTML = ''
|
||||
|
||||
const isDark = cfg.styleMode === 'dark'
|
||||
|
||||
try {
|
||||
if (!window.TACaptcha) {
|
||||
const script = document.createElement('script')
|
||||
script.src = '/sdk/captcha.js'
|
||||
document.head.appendChild(script)
|
||||
await new Promise((resolve, reject) => {
|
||||
script.onload = resolve
|
||||
script.onerror = () => reject(new Error('加载 captcha.js 失败'))
|
||||
})
|
||||
}
|
||||
|
||||
const trigger = document.createElement('div')
|
||||
trigger.id = 'test-captcha-btn'
|
||||
trigger.style.cssText = 'width:100%;'
|
||||
|
||||
triggerEl.appendChild(trigger)
|
||||
|
||||
log('req', 'POST ' + cfg.serverUrl + '/challenge/generate?type=' + (cfg.type || 'auto'))
|
||||
log('info', 'X-Site-Key: ' + cfg.siteKey)
|
||||
|
||||
captchaInstance.value = window.TACaptcha.init({
|
||||
elId: 'test-captcha-btn',
|
||||
siteKey: cfg.siteKey,
|
||||
type: cfg.type,
|
||||
scene: cfg.scene,
|
||||
serverUrl: cfg.serverUrl,
|
||||
logoUrl: '/logo/logo.svg',
|
||||
theme: isDark ? 'dark' : 'light',
|
||||
mode: 'click',
|
||||
onSuccess: (result) => {
|
||||
log('res', '验证成功! token: ' + result.data.verifyToken.substring(0, 40) + '...')
|
||||
verifyResult.value = { success: true, token: result.data.verifyToken }
|
||||
statusMsg.value = '验证成功'
|
||||
},
|
||||
onFail: (err) => {
|
||||
log('err', '验证失败: ' + JSON.stringify(err))
|
||||
verifyResult.value = { success: false, error: err?.msg || '基础校验失败' }
|
||||
statusMsg.value = '验证失败'
|
||||
},
|
||||
onOpen: () => log('info', '弹窗打开'),
|
||||
onClose: () => log('info', '弹窗关闭'),
|
||||
})
|
||||
|
||||
log('res', '初始化完成')
|
||||
statusMsg.value = '请点击上方按钮进行验证'
|
||||
} catch (e) {
|
||||
log('err', '初始化失败: ' + e.message)
|
||||
statusMsg.value = '初始化失败: ' + e.message
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadSites)
|
||||
</script>
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<n-space vertical :size="24">
|
||||
<n-h2>仪表盘</n-h2>
|
||||
<n-grid :cols="4" :x-gap="16" :y-gap="16">
|
||||
<n-gi><n-statistic label="请求总数" :value="stats.total" /></n-gi>
|
||||
<n-gi><n-statistic label="通过总数" :value="stats.success" /></n-gi>
|
||||
<n-gi><n-statistic label="失败总数" :value="stats.fail" /></n-gi>
|
||||
<n-gi><n-statistic label="总通过率" :value="stats.passRate" /></n-gi>
|
||||
</n-grid>
|
||||
<n-h3>验证码类型</n-h3>
|
||||
<n-grid :cols="4" :x-gap="12" :y-gap="12">
|
||||
<n-gi v-for="t in types" :key="t.key">
|
||||
<n-card size="small" :title="t.label">
|
||||
<template #header-extra><n-tag size="small">{{ t.key }}</n-tag></template>
|
||||
已注册
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { NSpace, NH2, NH3, NGrid, NGi, NStatistic, NCard, NTag } from 'naive-ui'
|
||||
import axios from 'axios'
|
||||
|
||||
const stats = ref({ total: 0, success: 0, fail: 0, passRate: '0%' })
|
||||
const types = [
|
||||
{ key: 'SLIDER', label: '滑块验证' },
|
||||
{ key: 'SLIDER_V2', label: '增强滑块' },
|
||||
{ key: 'ROTATE', label: '旋转验证' },
|
||||
{ key: 'CONCAT', label: '滑动还原' },
|
||||
{ key: 'WORD_IMAGE_CLICK', label: '文字点选' },
|
||||
{ key: 'ICON_CLICK', label: '图标点选' },
|
||||
{ key: 'WORD_ORDER_CLICK', label: '语序点选' },
|
||||
{ key: 'CURVE_SLIDER', label: '曲线滑块' },
|
||||
{ key: 'ANGLE', label: '角度验证' },
|
||||
{ key: 'SCRATCH', label: '刮刮乐' },
|
||||
{ key: 'JIGSAW', label: '乱序拼图' },
|
||||
{ key: 'CURVE_DRAW', label: '曲线绘制' },
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const resp = await axios.get('/api/admin/stats?days=7')
|
||||
if (resp.data?.code === 200) stats.value = resp.data.data
|
||||
} catch {}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<n-space vertical :size="20">
|
||||
<n-h2>站点管理
|
||||
<template #suffix>
|
||||
<n-button type="primary" @click="showCreate = true">创建站点</n-button>
|
||||
</template>
|
||||
</n-h2>
|
||||
<n-data-table :columns="columns" :data="sites" :pagination="{ pageSize: 20 }" :bordered="false" />
|
||||
|
||||
<n-modal v-model:show="showCreate" preset="dialog" title="创建站点" positive-text="创建" @positive-click="createSite">
|
||||
<n-form>
|
||||
<n-form-item label="站点名称"><n-input v-model:value="form.name" /></n-form-item>
|
||||
<n-form-item label="域名"><n-input v-model:value="form.domain" placeholder="lovehome.safina520.cn" /></n-form-item>
|
||||
<n-form-item label="验证码类型">
|
||||
<n-checkbox-group v-model:value="form.captchaTypes">
|
||||
<n-space>
|
||||
<n-checkbox v-for="t in allTypes" :key="t.value" :value="t.value" :label="t.label" />
|
||||
</n-space>
|
||||
</n-checkbox-group>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-modal>
|
||||
|
||||
<n-modal v-model:show="showDetail" preset="dialog" title="站点详情" :show-action="false" style="width:600px">
|
||||
<n-descriptions v-if="detailSite" bordered :column="1" label-placement="left" size="small">
|
||||
<n-descriptions-item label="ID">{{ detailSite.id }}</n-descriptions-item>
|
||||
<n-descriptions-item label="名称">{{ detailSite.name }}</n-descriptions-item>
|
||||
<n-descriptions-item label="域名">{{ detailSite.domain }}</n-descriptions-item>
|
||||
<n-descriptions-item label="SiteKey">
|
||||
<n-tag size="small" :bordered="false">{{ detailSite.siteKey }}</n-tag>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="SecretKey">
|
||||
<n-tag size="small" :bordered="false">{{ detailSite.secretKey }}</n-tag>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="验证等级">{{ detailSite.verifyLevel }}</n-descriptions-item>
|
||||
<n-descriptions-item label="QPS">{{ detailSite.qps }}</n-descriptions-item>
|
||||
<n-descriptions-item label="日限制">{{ detailSite.dailyLimit }}</n-descriptions-item>
|
||||
<n-descriptions-item label="验证码类型">
|
||||
<n-space>
|
||||
<n-tag v-for="t in (detailSite.captchaTypes || [])" :key="t" size="small">{{ t }}</n-tag>
|
||||
</n-space>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="状态">
|
||||
<n-tag :type="detailSite.isEnabled ? 'success' : 'error'" size="small">{{ detailSite.isEnabled ? '启用' : '禁用' }}</n-tag>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="轨迹验证">{{ detailSite.trackValidationEnabled ? '开启' : '关闭' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="对抗扰动">{{ detailSite.obfuscationEnabled ? '开启' : '关闭' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="端到端加密">{{ detailSite.encryptionEnabled ? '开启' : '关闭' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="创建时间">{{ formatTime(detailSite.createdAt) }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-modal>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, h } from 'vue'
|
||||
import { NSpace, NH2, NButton, NDataTable, NModal, NForm, NFormItem, NInput, NTag, NCheckboxGroup, NCheckbox, NDescriptions, NDescriptionsItem } from 'naive-ui'
|
||||
import axios from 'axios'
|
||||
|
||||
const sites = ref([])
|
||||
const showCreate = ref(false)
|
||||
const showDetail = ref(false)
|
||||
const detailSite = ref(null)
|
||||
const form = ref({ name: '', domain: '', captchaTypes: ['SLIDER', 'ROTATE', 'ICON_CLICK'] })
|
||||
|
||||
const allTypes = [
|
||||
{ value: 'SLIDER', label: '滑块验证' },
|
||||
{ value: 'SLIDER_V2', label: '增强滑块' },
|
||||
{ value: 'ROTATE', label: '旋转验证' },
|
||||
{ value: 'CONCAT', label: '滑动还原' },
|
||||
{ value: 'WORD_IMAGE_CLICK', label: '文字点选' },
|
||||
{ value: 'ICON_CLICK', label: '图标点选' },
|
||||
{ value: 'WORD_ORDER_CLICK', label: '语序点选' },
|
||||
{ value: 'CURVE_SLIDER', label: '曲线滑块' },
|
||||
{ value: 'ANGLE', label: '角度验证' },
|
||||
{ value: 'SCRATCH', label: '刮刮乐' },
|
||||
{ value: 'JIGSAW', label: '乱序拼图' },
|
||||
{ value: 'CURVE_DRAW', label: '曲线绘制' },
|
||||
]
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', key: 'id', width: 60 },
|
||||
{ title: '名称', key: 'name', width: 120 },
|
||||
{ title: '域名', key: 'domain', width: 160 },
|
||||
{
|
||||
title: 'SiteKey', key: 'siteKey', width: 200,
|
||||
render: (row) => h(NTag, { size: 'small', bordered: false }, () => row.siteKey)
|
||||
},
|
||||
{
|
||||
title: '验证等级', key: 'verifyLevel', width: 90,
|
||||
render: (row) => h(NTag, {
|
||||
type: row.verifyLevel === 'HIGH' ? 'error' : row.verifyLevel === 'MEDIUM' ? 'warning' : 'info', size: 'small'
|
||||
}, () => row.verifyLevel)
|
||||
},
|
||||
{
|
||||
title: '状态', key: 'isEnabled', width: 70,
|
||||
render: (row) => h(NTag, { type: row.isEnabled ? 'success' : 'error', size: 'small' }, () => row.isEnabled ? '启用' : '禁用')
|
||||
},
|
||||
{
|
||||
title: '创建时间', key: 'createdAt', width: 160,
|
||||
render: (row) => formatTime(row.createdAt)
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'actions', width: 200,
|
||||
render: (row) => h('div', { style: 'display:flex;gap:8px;' }, [
|
||||
h(NButton, { size: 'small', onClick: () => viewSite(row) }, () => '详情'),
|
||||
h(NButton, { size: 'small', onClick: () => toggleSite(row) }, () => row.isEnabled ? '禁用' : '启用'),
|
||||
h(NButton, { size: 'small', type: 'error', onClick: () => deleteSite(row.id) }, () => '删除'),
|
||||
])
|
||||
},
|
||||
]
|
||||
|
||||
function formatTime(v) {
|
||||
if (!v) return '-'
|
||||
return new Date(v).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
async function loadSites() {
|
||||
try {
|
||||
const resp = await axios.get('/api/admin/sites')
|
||||
if (resp.data?.code === 200) sites.value = resp.data.data?.content || []
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function createSite() {
|
||||
await axios.post('/api/admin/sites', {
|
||||
name: form.value.name,
|
||||
domain: form.value.domain,
|
||||
captchaTypes: form.value.captchaTypes,
|
||||
})
|
||||
form.value = { name: '', domain: '', captchaTypes: ['SLIDER', 'ROTATE', 'ICON_CLICK'] }
|
||||
await loadSites()
|
||||
}
|
||||
|
||||
function viewSite(row) {
|
||||
detailSite.value = row
|
||||
showDetail.value = true
|
||||
}
|
||||
|
||||
async function toggleSite(row) {
|
||||
row.isEnabled = !row.isEnabled
|
||||
await axios.put(`/api/admin/sites/${row.id}`, row)
|
||||
await loadSites()
|
||||
}
|
||||
|
||||
async function deleteSite(id) {
|
||||
await axios.delete(`/api/admin/sites/${id}`)
|
||||
await loadSites()
|
||||
}
|
||||
|
||||
onMounted(loadSites)
|
||||
</script>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<n-space vertical :size="20">
|
||||
<n-h2>统计监控</n-h2>
|
||||
<n-grid :cols="4" :x-gap="16" :y-gap="16">
|
||||
<n-gi><n-statistic label="请求总数" :value="stats.total" /></n-gi>
|
||||
<n-gi><n-statistic label="通过总数" :value="stats.success" /></n-gi>
|
||||
<n-gi><n-statistic label="失败总数" :value="stats.fail" /></n-gi>
|
||||
<n-gi><n-statistic label="总通过率" :value="stats.passRate" /></n-gi>
|
||||
</n-grid>
|
||||
<n-h3>IP黑名单管理</n-h3>
|
||||
<n-space>
|
||||
<n-input v-model:value="banForm.ip" placeholder="IP地址" style="width:200px" />
|
||||
<n-input v-model:value="banForm.reason" placeholder="封禁原因" style="width:200px" />
|
||||
<n-button type="error" @click="banIp">封禁IP</n-button>
|
||||
</n-space>
|
||||
<n-data-table :columns="ipColumns" :data="bannedIps" :bordered="false" />
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, h } from 'vue'
|
||||
import { NSpace, NH2, NH3, NGrid, NGi, NStatistic, NDataTable, NButton, NInput, NTag } from 'naive-ui'
|
||||
import axios from 'axios'
|
||||
|
||||
const stats = ref({ total: 0, success: 0, fail: 0, passRate: '0%' })
|
||||
const bannedIps = ref([])
|
||||
const banForm = ref({ ip: '', reason: '' })
|
||||
|
||||
const ipColumns = [
|
||||
{ title: 'IP', key: 'ip' },
|
||||
{ title: '原因', key: 'reason' },
|
||||
{ title: '解封时间', key: 'banUntil', render: (row) => row.banUntil ? new Date(row.banUntil).toLocaleString('zh-CN') : '永久' },
|
||||
{
|
||||
title: '操作', key: 'actions',
|
||||
render: (row) => h(NButton, { size: 'small', type: 'warning', onClick: () => unbanIp(row.ip) }, () => '解封')
|
||||
},
|
||||
]
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const resp = await axios.get('/api/admin/stats?days=7')
|
||||
if (resp.data?.code === 200) stats.value = resp.data.data
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function banIp() {
|
||||
if (!banForm.value.ip) return
|
||||
await axios.post('/api/admin/ip-blacklist', {
|
||||
ip: banForm.value.ip,
|
||||
reason: banForm.value.reason,
|
||||
durationMs: 3600000,
|
||||
})
|
||||
banForm.value = { ip: '', reason: '' }
|
||||
await loadBannedIps()
|
||||
}
|
||||
|
||||
async function unbanIp(ip) {
|
||||
await axios.delete(`/api/admin/ip-blacklist/${ip}`)
|
||||
await loadBannedIps()
|
||||
}
|
||||
|
||||
async function loadBannedIps() {
|
||||
bannedIps.value = []
|
||||
}
|
||||
|
||||
onMounted(() => { loadStats(); loadBannedIps() })
|
||||
</script>
|
||||
Reference in New Issue
Block a user