feat: 添加安全仪表板前端页面
- 新增 SecurityPage.vue: 统计卡片、威胁事件表格、封禁管理、风险查询 - 新增 api/security.js: 安全相关API封装 - 路由添加 /security 页面 - 侧边栏添加"安全防护"菜单项 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
59
admin-frontend/src/api/security.js
Normal file
59
admin-frontend/src/api/security.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { api } from './client'
|
||||
|
||||
export async function getDashboard() {
|
||||
const { data } = await api.get('/admin/security/dashboard')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getThreats(params) {
|
||||
const { data } = await api.get('/admin/security/threats', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getBannedIps() {
|
||||
const { data } = await api.get('/admin/security/banned-ips')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getBannedUsers() {
|
||||
const { data } = await api.get('/admin/security/banned-users')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function banIp(payload) {
|
||||
const { data } = await api.post('/admin/security/ban-ip', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function unbanIp(ip) {
|
||||
const { data } = await api.post('/admin/security/unban-ip', { ip })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function banUser(payload) {
|
||||
const { data } = await api.post('/admin/security/ban-user', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function unbanUser(userId) {
|
||||
const { data } = await api.post('/admin/security/unban-user', { user_id: userId })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getIpRisk(ip) {
|
||||
const safeIp = encodeURIComponent(String(ip || '').trim())
|
||||
const { data } = await api.get(`/admin/security/ip-risk/${safeIp}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getUserRisk(userId) {
|
||||
const safeUserId = encodeURIComponent(String(userId || '').trim())
|
||||
const { data } = await api.get(`/admin/security/user-risk/${safeUserId}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function cleanup() {
|
||||
const { data } = await api.post('/admin/security/cleanup', {})
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ChatLineSquare,
|
||||
Document,
|
||||
List,
|
||||
Lock,
|
||||
Message,
|
||||
Setting,
|
||||
Tools,
|
||||
@@ -104,6 +105,7 @@ const menuItems = [
|
||||
{ path: '/logs', label: '任务日志', icon: List },
|
||||
{ path: '/announcements', label: '公告', icon: Bell },
|
||||
{ path: '/email', label: '邮件', icon: Message },
|
||||
{ path: '/security', label: '安全防护', icon: Lock },
|
||||
{ path: '/system', label: '系统配置', icon: Tools },
|
||||
{ path: '/settings', label: '设置', icon: Setting },
|
||||
]
|
||||
|
||||
804
admin-frontend/src/pages/SecurityPage.vue
Normal file
804
admin-frontend/src/pages/SecurityPage.vue
Normal file
@@ -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>
|
||||
@@ -8,6 +8,7 @@ const FeedbacksPage = () => import('../pages/FeedbacksPage.vue')
|
||||
const LogsPage = () => import('../pages/LogsPage.vue')
|
||||
const AnnouncementsPage = () => import('../pages/AnnouncementsPage.vue')
|
||||
const EmailPage = () => import('../pages/EmailPage.vue')
|
||||
const SecurityPage = () => import('../pages/SecurityPage.vue')
|
||||
const SystemPage = () => import('../pages/SystemPage.vue')
|
||||
const SettingsPage = () => import('../pages/SettingsPage.vue')
|
||||
|
||||
@@ -25,6 +26,7 @@ const routes = [
|
||||
{ path: '/logs', name: 'logs', component: LogsPage },
|
||||
{ path: '/announcements', name: 'announcements', component: AnnouncementsPage },
|
||||
{ path: '/email', name: 'email', component: EmailPage },
|
||||
{ path: '/security', name: 'security', component: SecurityPage },
|
||||
{ path: '/system', name: 'system', component: SystemPage },
|
||||
{ path: '/settings', name: 'settings', component: SettingsPage },
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user