- 新增 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>
805 lines
24 KiB
Vue
805 lines
24 KiB
Vue
<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>
|