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:
@@ -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>
|
||||
Reference in New Issue
Block a user