|
|
|
@@ -0,0 +1,804 @@
|
|
|
|
|
<script setup>
|
|
|
|
|
import { computed, onMounted, ref } from 'vue'
|
|
|
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
banIp,
|
|
|
|
|
banUser,
|
|
|
|
|
cleanup,
|
|
|
|
|
getBannedIps,
|
|
|
|
|
getBannedUsers,
|
|
|
|
|
getDashboard,
|
|
|
|
|
getIpRisk,
|
|
|
|
|
getThreats,
|
|
|
|
|
getUserRisk,
|
|
|
|
|
unbanIp,
|
|
|
|
|
unbanUser,
|
|
|
|
|
} from '../api/security'
|
|
|
|
|
|
|
|
|
|
const pageSize = 20
|
|
|
|
|
|
|
|
|
|
const activeTab = ref('threats')
|
|
|
|
|
|
|
|
|
|
const dashboardLoading = ref(false)
|
|
|
|
|
const dashboard = ref(null)
|
|
|
|
|
|
|
|
|
|
const threatsLoading = ref(false)
|
|
|
|
|
const threatItems = ref([])
|
|
|
|
|
const threatTotal = ref(0)
|
|
|
|
|
const threatPage = ref(1)
|
|
|
|
|
const threatTypeFilter = ref('')
|
|
|
|
|
const threatSeverityFilter = ref('')
|
|
|
|
|
|
|
|
|
|
const bansLoading = ref(false)
|
|
|
|
|
const bannedIps = ref([])
|
|
|
|
|
const bannedUsers = ref([])
|
|
|
|
|
const banTab = ref('ips')
|
|
|
|
|
|
|
|
|
|
const banDialogOpen = ref(false)
|
|
|
|
|
const banSubmitting = ref(false)
|
|
|
|
|
const banForm = ref({
|
|
|
|
|
kind: 'ip',
|
|
|
|
|
ip: '',
|
|
|
|
|
user_id: '',
|
|
|
|
|
reason: '',
|
|
|
|
|
duration_hours: 24,
|
|
|
|
|
permanent: false,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const riskTab = ref('ip')
|
|
|
|
|
const riskLoading = ref(false)
|
|
|
|
|
const riskIpInput = ref('')
|
|
|
|
|
const riskUserIdInput = ref('')
|
|
|
|
|
const riskResult = ref(null)
|
|
|
|
|
const riskResultKind = ref('')
|
|
|
|
|
|
|
|
|
|
const commonThreatTypes = [
|
|
|
|
|
'sql_injection',
|
|
|
|
|
'xss',
|
|
|
|
|
'path_traversal',
|
|
|
|
|
'command_injection',
|
|
|
|
|
'ssrf',
|
|
|
|
|
'scanner',
|
|
|
|
|
'bruteforce',
|
|
|
|
|
'csrf',
|
|
|
|
|
'xxe',
|
|
|
|
|
'file_upload',
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
function normalizeCount(value) {
|
|
|
|
|
const n = Number(value)
|
|
|
|
|
return Number.isFinite(n) ? n : 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function scoreMeta(score) {
|
|
|
|
|
const n = Number(score || 0)
|
|
|
|
|
if (n >= 80) return { label: '高', type: 'danger' }
|
|
|
|
|
if (n >= 50) return { label: '中', type: 'warning' }
|
|
|
|
|
return { label: '低', type: 'success' }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatExpires(expiresAt) {
|
|
|
|
|
const text = String(expiresAt || '').trim()
|
|
|
|
|
return text ? text : '永久'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function payloadTooltip(row) {
|
|
|
|
|
const parts = []
|
|
|
|
|
if (row?.field_name) parts.push(`字段: ${row.field_name}`)
|
|
|
|
|
if (row?.rule) parts.push(`规则: ${row.rule}`)
|
|
|
|
|
if (row?.matched) parts.push(`匹配: ${row.matched}`)
|
|
|
|
|
if (row?.value_preview) parts.push(`值: ${row.value_preview}`)
|
|
|
|
|
return parts.length ? parts.join(' · ') : '-'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pathText(row) {
|
|
|
|
|
const method = String(row?.request_method || '').trim()
|
|
|
|
|
const path = String(row?.request_path || '').trim()
|
|
|
|
|
const combined = `${method} ${path}`.trim()
|
|
|
|
|
return combined || '-'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const threatTypeOptions = computed(() => {
|
|
|
|
|
const seen = new Set(commonThreatTypes)
|
|
|
|
|
const recent = dashboard.value?.recent_threat_events || []
|
|
|
|
|
for (const item of recent) {
|
|
|
|
|
const t = String(item?.threat_type || '').trim()
|
|
|
|
|
if (t) seen.add(t)
|
|
|
|
|
}
|
|
|
|
|
for (const item of threatItems.value || []) {
|
|
|
|
|
const t = String(item?.threat_type || '').trim()
|
|
|
|
|
if (t) seen.add(t)
|
|
|
|
|
}
|
|
|
|
|
return Array.from(seen)
|
|
|
|
|
.sort((a, b) => a.localeCompare(b))
|
|
|
|
|
.map((t) => ({ label: t, value: t }))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const dashboardCards = computed(() => {
|
|
|
|
|
const d = dashboard.value || {}
|
|
|
|
|
return [
|
|
|
|
|
{ key: 'threat_events_24h', label: '最近24小时威胁事件', value: normalizeCount(d.threat_events_24h) },
|
|
|
|
|
{ key: 'banned_ip_count', label: '当前封禁IP数', value: normalizeCount(d.banned_ip_count) },
|
|
|
|
|
{ key: 'banned_user_count', label: '当前封禁用户数', value: normalizeCount(d.banned_user_count) },
|
|
|
|
|
]
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const threatTotalPages = computed(() => Math.max(1, Math.ceil((threatTotal.value || 0) / pageSize)))
|
|
|
|
|
|
|
|
|
|
async function loadDashboard() {
|
|
|
|
|
dashboardLoading.value = true
|
|
|
|
|
try {
|
|
|
|
|
dashboard.value = await getDashboard()
|
|
|
|
|
} catch {
|
|
|
|
|
dashboard.value = null
|
|
|
|
|
} finally {
|
|
|
|
|
dashboardLoading.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadThreats() {
|
|
|
|
|
threatsLoading.value = true
|
|
|
|
|
try {
|
|
|
|
|
const params = {
|
|
|
|
|
page: threatPage.value,
|
|
|
|
|
per_page: pageSize,
|
|
|
|
|
}
|
|
|
|
|
if (threatTypeFilter.value) params.event_type = threatTypeFilter.value
|
|
|
|
|
if (threatSeverityFilter.value) params.severity = threatSeverityFilter.value
|
|
|
|
|
|
|
|
|
|
const data = await getThreats(params)
|
|
|
|
|
threatItems.value = data?.items || []
|
|
|
|
|
threatTotal.value = data?.total || 0
|
|
|
|
|
} catch {
|
|
|
|
|
threatItems.value = []
|
|
|
|
|
threatTotal.value = 0
|
|
|
|
|
} finally {
|
|
|
|
|
threatsLoading.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadBans() {
|
|
|
|
|
if (bansLoading.value) return
|
|
|
|
|
bansLoading.value = true
|
|
|
|
|
try {
|
|
|
|
|
const [ipsRes, usersRes] = await Promise.allSettled([getBannedIps(), getBannedUsers()])
|
|
|
|
|
bannedIps.value = ipsRes.status === 'fulfilled' ? ipsRes.value?.items || [] : []
|
|
|
|
|
bannedUsers.value = usersRes.status === 'fulfilled' ? usersRes.value?.items || [] : []
|
|
|
|
|
} finally {
|
|
|
|
|
bansLoading.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function refreshAll() {
|
|
|
|
|
await Promise.allSettled([loadDashboard(), loadThreats(), loadBans()])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onThreatFilter() {
|
|
|
|
|
threatPage.value = 1
|
|
|
|
|
loadThreats()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onThreatReset() {
|
|
|
|
|
threatTypeFilter.value = ''
|
|
|
|
|
threatSeverityFilter.value = ''
|
|
|
|
|
threatPage.value = 1
|
|
|
|
|
loadThreats()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resetBanForm() {
|
|
|
|
|
banForm.value = {
|
|
|
|
|
kind: 'ip',
|
|
|
|
|
ip: '',
|
|
|
|
|
user_id: '',
|
|
|
|
|
reason: '',
|
|
|
|
|
duration_hours: 24,
|
|
|
|
|
permanent: false,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openBanDialog(kind = 'ip', preset = {}) {
|
|
|
|
|
resetBanForm()
|
|
|
|
|
banForm.value.kind = kind === 'user' ? 'user' : 'ip'
|
|
|
|
|
if (banForm.value.kind === 'ip') {
|
|
|
|
|
banForm.value.ip = String(preset.ip || '').trim()
|
|
|
|
|
} else {
|
|
|
|
|
banForm.value.user_id = String(preset.user_id || '').trim()
|
|
|
|
|
}
|
|
|
|
|
if (preset.reason) banForm.value.reason = String(preset.reason || '').trim()
|
|
|
|
|
banDialogOpen.value = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function submitBan() {
|
|
|
|
|
const kind = banForm.value.kind
|
|
|
|
|
const reason = String(banForm.value.reason || '').trim()
|
|
|
|
|
const permanent = Boolean(banForm.value.permanent)
|
|
|
|
|
const durationHours = Number(banForm.value.duration_hours || 24)
|
|
|
|
|
|
|
|
|
|
if (!reason) {
|
|
|
|
|
ElMessage.error('原因不能为空')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (kind === 'ip') {
|
|
|
|
|
const ip = String(banForm.value.ip || '').trim()
|
|
|
|
|
if (!ip) {
|
|
|
|
|
ElMessage.error('IP不能为空')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
banSubmitting.value = true
|
|
|
|
|
try {
|
|
|
|
|
await banIp({ ip, reason, duration_hours: durationHours, permanent })
|
|
|
|
|
ElMessage.success('IP已封禁')
|
|
|
|
|
banDialogOpen.value = false
|
|
|
|
|
await Promise.allSettled([loadDashboard(), loadBans()])
|
|
|
|
|
} catch {
|
|
|
|
|
// handled by interceptor
|
|
|
|
|
} finally {
|
|
|
|
|
banSubmitting.value = false
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const userIdRaw = String(banForm.value.user_id || '').trim()
|
|
|
|
|
const userId = Number.parseInt(userIdRaw, 10)
|
|
|
|
|
if (!Number.isFinite(userId)) {
|
|
|
|
|
ElMessage.error('用户ID无效')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
banSubmitting.value = true
|
|
|
|
|
try {
|
|
|
|
|
await banUser({ user_id: userId, reason, duration_hours: durationHours, permanent })
|
|
|
|
|
ElMessage.success('用户已封禁')
|
|
|
|
|
banDialogOpen.value = false
|
|
|
|
|
await Promise.allSettled([loadDashboard(), loadBans()])
|
|
|
|
|
} catch {
|
|
|
|
|
// handled by interceptor
|
|
|
|
|
} finally {
|
|
|
|
|
banSubmitting.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function onUnbanIp(ip) {
|
|
|
|
|
const ipText = String(ip || '').trim()
|
|
|
|
|
if (!ipText) return
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await ElMessageBox.confirm(`确定解除对 IP ${ipText} 的封禁吗?`, '解除封禁', {
|
|
|
|
|
confirmButtonText: '解除',
|
|
|
|
|
cancelButtonText: '取消',
|
|
|
|
|
type: 'warning',
|
|
|
|
|
})
|
|
|
|
|
} catch {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await unbanIp(ipText)
|
|
|
|
|
ElMessage.success('已解除IP封禁')
|
|
|
|
|
await Promise.allSettled([loadDashboard(), loadBans()])
|
|
|
|
|
} catch {
|
|
|
|
|
// handled by interceptor
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function onUnbanUser(userId) {
|
|
|
|
|
const id = Number.parseInt(String(userId || '').trim(), 10)
|
|
|
|
|
if (!Number.isFinite(id)) return
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await ElMessageBox.confirm(`确定解除对 用户ID ${id} 的封禁吗?`, '解除封禁', {
|
|
|
|
|
confirmButtonText: '解除',
|
|
|
|
|
cancelButtonText: '取消',
|
|
|
|
|
type: 'warning',
|
|
|
|
|
})
|
|
|
|
|
} catch {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await unbanUser(id)
|
|
|
|
|
ElMessage.success('已解除用户封禁')
|
|
|
|
|
await Promise.allSettled([loadDashboard(), loadBans()])
|
|
|
|
|
} catch {
|
|
|
|
|
// handled by interceptor
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function jumpToIpRisk(ip) {
|
|
|
|
|
const ipText = String(ip || '').trim()
|
|
|
|
|
if (!ipText) return
|
|
|
|
|
activeTab.value = 'risk'
|
|
|
|
|
riskTab.value = 'ip'
|
|
|
|
|
riskIpInput.value = ipText
|
|
|
|
|
queryIpRisk()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function jumpToUserRisk(userId) {
|
|
|
|
|
const idText = String(userId || '').trim()
|
|
|
|
|
if (!idText) return
|
|
|
|
|
activeTab.value = 'risk'
|
|
|
|
|
riskTab.value = 'user'
|
|
|
|
|
riskUserIdInput.value = idText
|
|
|
|
|
queryUserRisk()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function queryIpRisk() {
|
|
|
|
|
const ip = String(riskIpInput.value || '').trim()
|
|
|
|
|
if (!ip) {
|
|
|
|
|
ElMessage.error('请输入IP')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
riskLoading.value = true
|
|
|
|
|
try {
|
|
|
|
|
riskResult.value = await getIpRisk(ip)
|
|
|
|
|
riskResultKind.value = 'ip'
|
|
|
|
|
} catch {
|
|
|
|
|
riskResult.value = null
|
|
|
|
|
riskResultKind.value = ''
|
|
|
|
|
} finally {
|
|
|
|
|
riskLoading.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function queryUserRisk() {
|
|
|
|
|
const raw = String(riskUserIdInput.value || '').trim()
|
|
|
|
|
const userId = Number.parseInt(raw, 10)
|
|
|
|
|
if (!Number.isFinite(userId)) {
|
|
|
|
|
ElMessage.error('请输入有效的用户ID')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
riskLoading.value = true
|
|
|
|
|
try {
|
|
|
|
|
riskResult.value = await getUserRisk(userId)
|
|
|
|
|
riskResultKind.value = 'user'
|
|
|
|
|
} catch {
|
|
|
|
|
riskResult.value = null
|
|
|
|
|
riskResultKind.value = ''
|
|
|
|
|
} finally {
|
|
|
|
|
riskLoading.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openBanFromRisk() {
|
|
|
|
|
if (!riskResult.value || !riskResultKind.value) return
|
|
|
|
|
if (riskResultKind.value === 'ip') {
|
|
|
|
|
openBanDialog('ip', { ip: riskResult.value?.ip, reason: '风险查询手动封禁' })
|
|
|
|
|
} else {
|
|
|
|
|
openBanDialog('user', { user_id: riskResult.value?.user_id, reason: '风险查询手动封禁' })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function unbanFromRisk() {
|
|
|
|
|
if (!riskResult.value || !riskResultKind.value) return
|
|
|
|
|
if (riskResultKind.value === 'ip') {
|
|
|
|
|
await onUnbanIp(riskResult.value?.ip)
|
|
|
|
|
await queryIpRisk()
|
|
|
|
|
} else {
|
|
|
|
|
await onUnbanUser(riskResult.value?.user_id)
|
|
|
|
|
await queryUserRisk()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const cleanupLoading = ref(false)
|
|
|
|
|
|
|
|
|
|
async function onCleanup() {
|
|
|
|
|
try {
|
|
|
|
|
await ElMessageBox.confirm(
|
|
|
|
|
'确定清理过期封禁记录,并衰减风险分吗?\n\n该操作不会影响仍在有效期内的封禁。',
|
|
|
|
|
'清理过期记录',
|
|
|
|
|
{ confirmButtonText: '清理', cancelButtonText: '取消', type: 'warning' },
|
|
|
|
|
)
|
|
|
|
|
} catch {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cleanupLoading.value = true
|
|
|
|
|
try {
|
|
|
|
|
await cleanup()
|
|
|
|
|
ElMessage.success('清理完成')
|
|
|
|
|
await refreshAll()
|
|
|
|
|
} catch {
|
|
|
|
|
// handled by interceptor
|
|
|
|
|
} finally {
|
|
|
|
|
cleanupLoading.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onMounted(async () => {
|
|
|
|
|
await refreshAll()
|
|
|
|
|
})
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
<div class="page-stack">
|
|
|
|
|
<div class="app-page-title">
|
|
|
|
|
<h2>安全防护</h2>
|
|
|
|
|
<div class="toolbar">
|
|
|
|
|
<el-button @click="refreshAll">刷新</el-button>
|
|
|
|
|
<el-button type="warning" plain :loading="cleanupLoading" @click="onCleanup">清理过期记录</el-button>
|
|
|
|
|
<el-button type="primary" @click="openBanDialog()">手动封禁</el-button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<el-row :gutter="12" class="stats-row">
|
|
|
|
|
<el-col v-for="it in dashboardCards" :key="it.key" :xs="24" :sm="8" :md="8" :lg="8" :xl="8">
|
|
|
|
|
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
|
|
|
|
|
<div class="stat-value">
|
|
|
|
|
<el-skeleton v-if="dashboardLoading" :rows="1" animated />
|
|
|
|
|
<template v-else>{{ it.value }}</template>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="stat-label">{{ it.label }}</div>
|
|
|
|
|
</el-card>
|
|
|
|
|
</el-col>
|
|
|
|
|
</el-row>
|
|
|
|
|
|
|
|
|
|
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
|
|
|
|
|
<el-tabs v-model="activeTab">
|
|
|
|
|
<el-tab-pane label="威胁事件" name="threats">
|
|
|
|
|
<div class="filters">
|
|
|
|
|
<el-select
|
|
|
|
|
v-model="threatTypeFilter"
|
|
|
|
|
placeholder="类型"
|
|
|
|
|
style="width: 220px"
|
|
|
|
|
filterable
|
|
|
|
|
clearable
|
|
|
|
|
allow-create
|
|
|
|
|
default-first-option
|
|
|
|
|
>
|
|
|
|
|
<el-option label="全部" value="" />
|
|
|
|
|
<el-option v-for="t in threatTypeOptions" :key="t.value" :label="t.label" :value="t.value" />
|
|
|
|
|
</el-select>
|
|
|
|
|
|
|
|
|
|
<el-select v-model="threatSeverityFilter" placeholder="严重程度" style="width: 200px" clearable>
|
|
|
|
|
<el-option label="全部" value="" />
|
|
|
|
|
<el-option label="高风险(>=80)" value="high" />
|
|
|
|
|
<el-option label="中风险(50-79)" value="medium" />
|
|
|
|
|
<el-option label="低风险(<50)" value="low" />
|
|
|
|
|
</el-select>
|
|
|
|
|
|
|
|
|
|
<el-button type="primary" @click="onThreatFilter">筛选</el-button>
|
|
|
|
|
<el-button @click="onThreatReset">重置</el-button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="table-wrap">
|
|
|
|
|
<el-table :data="threatItems" v-loading="threatsLoading" style="width: 100%">
|
|
|
|
|
<el-table-column prop="created_at" label="时间" width="180" />
|
|
|
|
|
<el-table-column label="类型" width="170">
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
<el-tag effect="light" type="info">{{ row.threat_type || 'unknown' }}</el-tag>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column label="严重程度" width="120">
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
<el-tag :type="scoreMeta(row.score).type" effect="light">
|
|
|
|
|
{{ scoreMeta(row.score).label }} ({{ row.score ?? 0 }})
|
|
|
|
|
</el-tag>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column label="IP" width="150">
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
<el-link v-if="row.ip" type="primary" :underline="false" @click="jumpToIpRisk(row.ip)">
|
|
|
|
|
{{ row.ip }}
|
|
|
|
|
</el-link>
|
|
|
|
|
<span v-else>-</span>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column label="用户" width="120">
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
<el-link
|
|
|
|
|
v-if="row.user_id !== null && row.user_id !== undefined"
|
|
|
|
|
type="primary"
|
|
|
|
|
:underline="false"
|
|
|
|
|
@click="jumpToUserRisk(row.user_id)"
|
|
|
|
|
>
|
|
|
|
|
{{ row.user_id }}
|
|
|
|
|
</el-link>
|
|
|
|
|
<span v-else>-</span>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column label="操作路径" min-width="220">
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
<el-tooltip :content="pathText(row)" placement="top" :show-after="300">
|
|
|
|
|
<span class="mono ellipsis">{{ pathText(row) }}</span>
|
|
|
|
|
</el-tooltip>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column label="Payload预览" min-width="240">
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
<el-tooltip :content="payloadTooltip(row)" placement="top" :show-after="300">
|
|
|
|
|
<span class="ellipsis">{{ row.value_preview || '-' }}</span>
|
|
|
|
|
</el-tooltip>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
</el-table>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="pagination">
|
|
|
|
|
<el-pagination
|
|
|
|
|
v-model:current-page="threatPage"
|
|
|
|
|
:page-size="pageSize"
|
|
|
|
|
:total="threatTotal"
|
|
|
|
|
layout="prev, pager, next, jumper, ->, total"
|
|
|
|
|
@current-change="loadThreats"
|
|
|
|
|
/>
|
|
|
|
|
<div class="page-hint app-muted">第 {{ threatPage }} / {{ threatTotalPages }} 页</div>
|
|
|
|
|
</div>
|
|
|
|
|
</el-tab-pane>
|
|
|
|
|
|
|
|
|
|
<el-tab-pane label="封禁管理" name="bans">
|
|
|
|
|
<div class="toolbar">
|
|
|
|
|
<el-button @click="loadBans">刷新封禁列表</el-button>
|
|
|
|
|
<el-button type="primary" @click="openBanDialog()">手动封禁</el-button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<el-tabs v-model="banTab" class="inner-tabs">
|
|
|
|
|
<el-tab-pane label="IP黑名单" name="ips">
|
|
|
|
|
<div class="table-wrap">
|
|
|
|
|
<el-table :data="bannedIps" v-loading="bansLoading" style="width: 100%">
|
|
|
|
|
<el-table-column label="IP" width="180">
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
<el-link type="primary" :underline="false" @click="jumpToIpRisk(row.ip)">
|
|
|
|
|
{{ row.ip || '-' }}
|
|
|
|
|
</el-link>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column prop="reason" label="原因" min-width="260" />
|
|
|
|
|
<el-table-column label="过期时间" width="190">
|
|
|
|
|
<template #default="{ row }">{{ formatExpires(row.expires_at) }}</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column label="操作" width="120" fixed="right">
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
<el-button size="small" type="danger" plain @click="onUnbanIp(row.ip)">解除</el-button>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
</el-table>
|
|
|
|
|
</div>
|
|
|
|
|
</el-tab-pane>
|
|
|
|
|
|
|
|
|
|
<el-tab-pane label="用户黑名单" name="users">
|
|
|
|
|
<div class="table-wrap">
|
|
|
|
|
<el-table :data="bannedUsers" v-loading="bansLoading" style="width: 100%">
|
|
|
|
|
<el-table-column label="用户ID" width="180">
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
<el-link type="primary" :underline="false" @click="jumpToUserRisk(row.user_id)">
|
|
|
|
|
{{ row.user_id ?? '-' }}
|
|
|
|
|
</el-link>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column prop="reason" label="原因" min-width="260" />
|
|
|
|
|
<el-table-column label="过期时间" width="190">
|
|
|
|
|
<template #default="{ row }">{{ formatExpires(row.expires_at) }}</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column label="操作" width="120" fixed="right">
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
<el-button size="small" type="danger" plain @click="onUnbanUser(row.user_id)">解除</el-button>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
</el-table>
|
|
|
|
|
</div>
|
|
|
|
|
</el-tab-pane>
|
|
|
|
|
</el-tabs>
|
|
|
|
|
</el-tab-pane>
|
|
|
|
|
|
|
|
|
|
<el-tab-pane label="风险查询" name="risk">
|
|
|
|
|
<el-tabs v-model="riskTab" class="inner-tabs">
|
|
|
|
|
<el-tab-pane label="IP查询" name="ip">
|
|
|
|
|
<div class="filters">
|
|
|
|
|
<el-input v-model="riskIpInput" placeholder="输入IP,如 1.2.3.4" style="width: 260px" clearable />
|
|
|
|
|
<el-button type="primary" :loading="riskLoading" @click="queryIpRisk">查询</el-button>
|
|
|
|
|
</div>
|
|
|
|
|
</el-tab-pane>
|
|
|
|
|
<el-tab-pane label="用户查询" name="user">
|
|
|
|
|
<div class="filters">
|
|
|
|
|
<el-input v-model="riskUserIdInput" placeholder="输入用户ID,如 123" style="width: 260px" clearable />
|
|
|
|
|
<el-button type="primary" :loading="riskLoading" @click="queryUserRisk">查询</el-button>
|
|
|
|
|
</div>
|
|
|
|
|
</el-tab-pane>
|
|
|
|
|
</el-tabs>
|
|
|
|
|
|
|
|
|
|
<el-card v-if="riskResult" shadow="never" :body-style="{ padding: '16px' }" class="sub-card">
|
|
|
|
|
<div class="risk-head">
|
|
|
|
|
<div class="risk-title">
|
|
|
|
|
<strong v-if="riskResultKind === 'ip'">IP: {{ riskResult.ip }}</strong>
|
|
|
|
|
<strong v-else>用户ID: {{ riskResult.user_id }}</strong>
|
|
|
|
|
<span class="app-muted">风险分</span>
|
|
|
|
|
<el-tag :type="scoreMeta(riskResult.risk_score).type" effect="light">
|
|
|
|
|
{{ riskResult.risk_score ?? 0 }}
|
|
|
|
|
</el-tag>
|
|
|
|
|
<el-tag v-if="riskResult.is_banned" type="danger" effect="light">已封禁</el-tag>
|
|
|
|
|
<el-tag v-else type="success" effect="light">未封禁</el-tag>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="toolbar">
|
|
|
|
|
<el-button v-if="!riskResult.is_banned" type="primary" plain @click="openBanFromRisk">封禁</el-button>
|
|
|
|
|
<el-button v-else type="danger" plain @click="unbanFromRisk">解除封禁</el-button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="table-wrap">
|
|
|
|
|
<el-table :data="riskResult.threat_history || []" v-loading="riskLoading" style="width: 100%">
|
|
|
|
|
<el-table-column prop="created_at" label="时间" width="180" />
|
|
|
|
|
<el-table-column label="类型" width="170">
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
<el-tag effect="light" type="info">{{ row.threat_type || 'unknown' }}</el-tag>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column label="严重程度" width="120">
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
<el-tag :type="scoreMeta(row.score).type" effect="light">
|
|
|
|
|
{{ scoreMeta(row.score).label }} ({{ row.score ?? 0 }})
|
|
|
|
|
</el-tag>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column label="操作路径" min-width="220">
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
<el-tooltip :content="pathText(row)" placement="top" :show-after="300">
|
|
|
|
|
<span class="mono ellipsis">{{ pathText(row) }}</span>
|
|
|
|
|
</el-tooltip>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column label="Payload预览" min-width="240">
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
<el-tooltip :content="payloadTooltip(row)" placement="top" :show-after="300">
|
|
|
|
|
<span class="ellipsis">{{ row.value_preview || '-' }}</span>
|
|
|
|
|
</el-tooltip>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
</el-table>
|
|
|
|
|
</div>
|
|
|
|
|
</el-card>
|
|
|
|
|
</el-tab-pane>
|
|
|
|
|
</el-tabs>
|
|
|
|
|
</el-card>
|
|
|
|
|
|
|
|
|
|
<el-dialog v-model="banDialogOpen" title="手动封禁" width="min(520px, 92vw)" @closed="resetBanForm">
|
|
|
|
|
<el-form label-width="120px">
|
|
|
|
|
<el-form-item label="类型">
|
|
|
|
|
<el-radio-group v-model="banForm.kind">
|
|
|
|
|
<el-radio-button label="ip">IP</el-radio-button>
|
|
|
|
|
<el-radio-button label="user">用户</el-radio-button>
|
|
|
|
|
</el-radio-group>
|
|
|
|
|
</el-form-item>
|
|
|
|
|
<el-form-item v-if="banForm.kind === 'ip'" label="IP">
|
|
|
|
|
<el-input v-model="banForm.ip" placeholder="例如 1.2.3.4" />
|
|
|
|
|
</el-form-item>
|
|
|
|
|
<el-form-item v-else label="用户ID">
|
|
|
|
|
<el-input v-model="banForm.user_id" placeholder="例如 123" />
|
|
|
|
|
</el-form-item>
|
|
|
|
|
<el-form-item label="原因">
|
|
|
|
|
<el-input v-model="banForm.reason" type="textarea" :rows="3" placeholder="请输入封禁原因" />
|
|
|
|
|
</el-form-item>
|
|
|
|
|
<el-form-item label="永久封禁">
|
|
|
|
|
<el-switch v-model="banForm.permanent" />
|
|
|
|
|
</el-form-item>
|
|
|
|
|
<el-form-item v-if="!banForm.permanent" label="持续(小时)">
|
|
|
|
|
<el-input-number v-model="banForm.duration_hours" :min="1" :max="8760" />
|
|
|
|
|
</el-form-item>
|
|
|
|
|
</el-form>
|
|
|
|
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
<div class="dialog-actions">
|
|
|
|
|
<div class="spacer"></div>
|
|
|
|
|
<el-button @click="banDialogOpen = false">取消</el-button>
|
|
|
|
|
<el-button type="primary" :loading="banSubmitting" @click="submitBan">确认封禁</el-button>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
</el-dialog>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
.page-stack {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.toolbar {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 10px;
|
|
|
|
|
align-items: center;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.stats-row {
|
|
|
|
|
margin-bottom: 2px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.card {
|
|
|
|
|
border-radius: var(--app-radius);
|
|
|
|
|
border: 1px solid var(--app-border);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.sub-card {
|
|
|
|
|
margin-top: 12px;
|
|
|
|
|
border-radius: var(--app-radius);
|
|
|
|
|
border: 1px solid var(--app-border);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.stat-card {
|
|
|
|
|
border-radius: var(--app-radius);
|
|
|
|
|
border: 1px solid var(--app-border);
|
|
|
|
|
box-shadow: var(--app-shadow);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.stat-value {
|
|
|
|
|
font-size: 22px;
|
|
|
|
|
font-weight: 800;
|
|
|
|
|
line-height: 1.1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.stat-label {
|
|
|
|
|
margin-top: 6px;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: var(--app-muted);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.filters {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
gap: 10px;
|
|
|
|
|
align-items: center;
|
|
|
|
|
margin-bottom: 12px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.table-wrap {
|
|
|
|
|
overflow-x: auto;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.ellipsis {
|
|
|
|
|
display: inline-block;
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.mono {
|
|
|
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.pagination {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
gap: 10px;
|
|
|
|
|
margin-top: 14px;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.page-hint {
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.inner-tabs {
|
|
|
|
|
margin-top: 6px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.risk-head {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
margin-bottom: 12px;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.risk-title {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 10px;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.dialog-actions {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 10px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.spacer {
|
|
|
|
|
flex: 1;
|
|
|
|
|
}
|
|
|
|
|
</style>
|