完善权限监控与安全中心
This commit is contained in:
@@ -0,0 +1,971 @@
|
||||
<template>
|
||||
<div class="security-center">
|
||||
<section class="audit-metrics">
|
||||
<article v-for="card in metricCards" :key="card.label" class="metric-card" :class="card.tone">
|
||||
<div class="metric-icon">
|
||||
<component :is="card.icon" />
|
||||
</div>
|
||||
<div class="metric-body">
|
||||
<span class="metric-label">{{ card.label }}</span>
|
||||
<strong class="metric-value">{{ card.value }}</strong>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="audit-grid">
|
||||
<article class="ranking-card">
|
||||
<div class="section-head">
|
||||
<div class="section-title-group">
|
||||
<h4>异常排行</h4>
|
||||
</div>
|
||||
<el-tag effect="plain" size="small" type="info">TOP {{ ipRiskStats.length }}</el-tag>
|
||||
</div>
|
||||
<div v-if="ipRiskStats.length" class="risk-rank-list">
|
||||
<div v-for="(item, index) in ipRiskStats" :key="item.ip" class="risk-rank-row">
|
||||
<span class="rank-no" :class="{ 'rank-top': index < 3 }">{{ String(index + 1).padStart(2, "0") }}</span>
|
||||
<div class="rank-user">
|
||||
<div class="rank-main-line">
|
||||
<strong>{{ item.ip }}</strong>
|
||||
<small>{{ item.location }}</small>
|
||||
</div>
|
||||
<div class="rank-meta-line">
|
||||
<span>{{ item.categoryText }}</span>
|
||||
<span>最后访问 {{ formatTime(item.lastSeenAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rank-count">
|
||||
<strong>{{ item.total }}</strong>
|
||||
<small :class="{ 'denied-highlight': item.highRiskCount > 0 }">{{ item.highRiskCount }} 高危</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无异常数据" :image-size="72" />
|
||||
</article>
|
||||
|
||||
<article class="ranking-card event-distribution-card">
|
||||
<div class="section-head">
|
||||
<div class="section-title-group">
|
||||
<h4>事件分布</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="distribution-section">
|
||||
<div class="distribution-section-head">
|
||||
<strong>访问的接口类型</strong>
|
||||
</div>
|
||||
<div v-if="endpointTypeStats.length" class="distribution-list">
|
||||
<div v-for="item in endpointTypeStats" :key="item.type" class="distribution-row">
|
||||
<div class="distribution-info">
|
||||
<strong>{{ item.type }}</strong>
|
||||
<small>{{ item.samplePath }}</small>
|
||||
</div>
|
||||
<div class="distribution-meter">
|
||||
<span :style="{ width: `${item.percent}%` }" />
|
||||
</div>
|
||||
<strong class="distribution-count">{{ item.total }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无接口类型数据" :image-size="72" />
|
||||
</div>
|
||||
|
||||
<div class="distribution-section compact-risk-section">
|
||||
<div class="distribution-section-head">
|
||||
<strong>风险等级</strong>
|
||||
</div>
|
||||
<div class="risk-level-list">
|
||||
<div v-for="item in riskLevelStats" :key="item.value" class="risk-level-row">
|
||||
<span class="risk-dot" :class="item.tone" />
|
||||
<span>{{ item.label }}</span>
|
||||
<div class="risk-level-meter">
|
||||
<span :class="item.tone" :style="{ width: `${item.percent}%` }" />
|
||||
</div>
|
||||
<strong>{{ item.total }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="security-table-wrap">
|
||||
<div class="section-head table-head">
|
||||
<div class="section-title-group">
|
||||
<h4>安全事件明细</h4>
|
||||
</div>
|
||||
<section class="audit-filters detail-filters">
|
||||
<el-select v-model="severityFilter" placeholder="风险等级" clearable style="width: 130px" @change="onFilterChange">
|
||||
<el-option v-for="item in severityOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
<el-select v-model="categoryFilter" placeholder="事件分类" clearable style="width: 150px" @change="onFilterChange">
|
||||
<el-option v-for="item in categoryOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
<el-select v-model="authFilter" placeholder="认证状态" clearable style="width: 140px" @change="onFilterChange">
|
||||
<el-option label="匿名" value="ANONYMOUS" />
|
||||
<el-option label="无效令牌" value="INVALID_TOKEN" />
|
||||
<el-option label="已认证" value="AUTHENTICATED" />
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="搜索 IP、位置、账号、路径或 UA"
|
||||
clearable
|
||||
class="security-search"
|
||||
:prefix-icon="SearchIcon"
|
||||
/>
|
||||
<el-button type="primary" :loading="loading" @click="loadSecurityEvents">刷新</el-button>
|
||||
</section>
|
||||
<el-tag effect="plain" size="small" type="info">{{ securityTotal }} 条</el-tag>
|
||||
</div>
|
||||
<el-table :data="paginatedEvents" v-loading="loading" class="security-table" table-layout="fixed">
|
||||
<el-table-column label="级别" width="96">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="severityTagType(row.severity)" effect="dark" size="small">{{ severityLabel(row.severity) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分类" width="130">
|
||||
<template #default="{ row }">
|
||||
<span class="category-pill">{{ categoryLabel(row.category) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="时间" width="160">
|
||||
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="IP" width="132">
|
||||
<template #default="{ row }">{{ row.client_ip || "未知 IP" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="位置" width="180">
|
||||
<template #default="{ row }">{{ formatIpLocation(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="账号" width="140" prop="account_label" />
|
||||
<el-table-column label="请求" min-width="320">
|
||||
<template #default="{ row }">
|
||||
<div class="request-cell">
|
||||
<strong>{{ row.method }} {{ row.status_code }} / {{ resolveEndpointType(row.path) }}</strong>
|
||||
<span>{{ row.path }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="耗时" width="86">
|
||||
<template #default="{ row }">{{ row.elapsed_ms.toFixed(1) }}ms</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="securityTotal > 0" class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:total="securityTotal"
|
||||
layout="prev, pager, next, sizes, total"
|
||||
small
|
||||
@size-change="onPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-drawer v-model="detailVisible" size="520px" direction="rtl" :show-close="false">
|
||||
<template #header>
|
||||
<div class="security-detail-head">
|
||||
<strong>安全事件详情</strong>
|
||||
<el-button size="small" @click="detailVisible = false">关闭</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="selectedEvent" class="security-detail">
|
||||
<div class="detail-grid">
|
||||
<div><span>级别</span><strong>{{ severityLabel(selectedEvent.severity) }}</strong></div>
|
||||
<div><span>分类</span><strong>{{ categoryLabel(selectedEvent.category) }}</strong></div>
|
||||
<div><span>状态码</span><strong>{{ selectedEvent.status_code }}</strong></div>
|
||||
<div><span>认证状态</span><strong>{{ authLabel(selectedEvent.auth_status) }}</strong></div>
|
||||
<div><span>IP</span><strong>{{ selectedEvent.client_ip || "未知" }}</strong></div>
|
||||
<div><span>位置</span><strong>{{ formatIpLocation(selectedEvent) }}</strong></div>
|
||||
<div><span>接口类型</span><strong>{{ resolveEndpointType(selectedEvent.path) }}</strong></div>
|
||||
<div><span>账号</span><strong>{{ selectedEvent.account_label }}</strong></div>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<span>请求路径</span>
|
||||
<code>{{ selectedEvent.method }} {{ selectedEvent.path }}</code>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<span>User Agent</span>
|
||||
<code>{{ selectedEvent.user_agent || "-" }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, ref, watch } from "vue";
|
||||
import { Search as SearchIcon } from "@element-plus/icons-vue";
|
||||
import { fetchSecurityAccessLogs } from "@/api/projectPermissions";
|
||||
import type { SecurityAccessLogItem } from "@/types/api";
|
||||
|
||||
type MetricTone = "blue" | "cyan" | "danger" | "indigo";
|
||||
|
||||
const MetricIconShield = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("path", { d: "M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" }),
|
||||
]);
|
||||
const MetricIconIp = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("circle", { cx: "12", cy: "10", r: "3" }),
|
||||
h("path", { d: "M12 21s7-5.2 7-11a7 7 0 1 0-14 0c0 5.8 7 11 7 11z" }),
|
||||
]);
|
||||
const MetricIconAlert = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("path", { d: "M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" }),
|
||||
h("line", { x1: "12", y1: "9", x2: "12", y2: "13" }),
|
||||
h("line", { x1: "12", y1: "17", x2: "12.01", y2: "17" }),
|
||||
]);
|
||||
const MetricIconServer = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("rect", { x: "3", y: "4", width: "18", height: "8", rx: "2" }),
|
||||
h("rect", { x: "3", y: "12", width: "18", height: "8", rx: "2" }),
|
||||
h("line", { x1: "7", y1: "8", x2: "7.01", y2: "8" }),
|
||||
h("line", { x1: "7", y1: "16", x2: "7.01", y2: "16" }),
|
||||
]);
|
||||
|
||||
const loading = ref(false);
|
||||
const statsEvents = ref<SecurityAccessLogItem[]>([]);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(10);
|
||||
const keyword = ref("");
|
||||
const severityFilter = ref("");
|
||||
const categoryFilter = ref("");
|
||||
const authFilter = ref("");
|
||||
const detailVisible = ref(false);
|
||||
const selectedEvent = ref<SecurityAccessLogItem | null>(null);
|
||||
|
||||
const severityOptions = [
|
||||
{ label: "严重", value: "CRITICAL", tone: "critical" },
|
||||
{ label: "高", value: "HIGH", tone: "high" },
|
||||
{ label: "中", value: "MEDIUM", tone: "medium" },
|
||||
{ label: "低", value: "LOW", tone: "low" },
|
||||
];
|
||||
|
||||
const categoryOptions = [
|
||||
{ label: "敏感路径探测", value: "PROBE" },
|
||||
{ label: "异常IP", value: "ABNORMAL_IP" },
|
||||
{ label: "无效令牌", value: "INVALID_TOKEN" },
|
||||
{ label: "服务异常", value: "SERVER_ERROR" },
|
||||
{ label: "匿名 API", value: "ANONYMOUS_API" },
|
||||
{ label: "普通 404", value: "NOT_FOUND_NOISE" },
|
||||
{ label: "其他", value: "OTHER" },
|
||||
];
|
||||
|
||||
const severityLabel = (value: string) => severityOptions.find((item) => item.value === value)?.label || value;
|
||||
const categoryLabel = (value: string) => categoryOptions.find((item) => item.value === value)?.label || value;
|
||||
|
||||
const authLabel = (value: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
ANONYMOUS: "匿名",
|
||||
INVALID_TOKEN: "无效令牌",
|
||||
AUTHENTICATED: "已认证",
|
||||
};
|
||||
return labels[value] || value;
|
||||
};
|
||||
|
||||
const severityTagType = (value: string) => {
|
||||
if (value === "CRITICAL" || value === "HIGH") return "danger";
|
||||
if (value === "MEDIUM") return "warning";
|
||||
return "info";
|
||||
};
|
||||
|
||||
const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
|
||||
const formatTime = (value: string) => new Date(value).toLocaleString("zh-CN", { hour12: false });
|
||||
|
||||
const isHighRisk = (event: SecurityAccessLogItem) => event.severity === "CRITICAL" || event.severity === "HIGH";
|
||||
|
||||
const fallbackIpLocation = (ip: string | null) => {
|
||||
if (!ip) return "未知位置";
|
||||
if (ip === "127.0.0.1" || ip === "::1") return "本机访问";
|
||||
if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[0-1])\.)/.test(ip)) return "内网地址";
|
||||
return "未知位置";
|
||||
};
|
||||
|
||||
const formatIpLocation = (row: SecurityAccessLogItem) => {
|
||||
const parts = [
|
||||
row.ip_country && row.ip_country !== "中国" ? row.ip_country : "",
|
||||
row.ip_province,
|
||||
row.ip_city,
|
||||
row.ip_isp,
|
||||
].filter(Boolean);
|
||||
if (parts.length) return parts.join(" / ");
|
||||
return row.ip_location || fallbackIpLocation(row.client_ip);
|
||||
};
|
||||
|
||||
const resolveEndpointType = (path: string) => {
|
||||
const normalizedPath = path.toLowerCase();
|
||||
if (normalizedPath.includes("/auth") || normalizedPath.includes("/login")) return "认证接口";
|
||||
if (normalizedPath.startsWith("/api/")) return "业务 API";
|
||||
if (normalizedPath.match(/\.(js|css|png|jpg|jpeg|svg|ico|map)$/)) return "静态资源";
|
||||
if ([".env", "wp-", "php", "admin", "shell", "config"].some((marker) => normalizedPath.includes(marker))) return "探测路径";
|
||||
return "其他请求";
|
||||
};
|
||||
|
||||
const detailFilteredEvents = computed(() => {
|
||||
const token = keyword.value.trim().toLowerCase();
|
||||
return statsEvents.value.filter((event) => {
|
||||
if (severityFilter.value && event.severity !== severityFilter.value) return false;
|
||||
if (categoryFilter.value && event.category !== categoryFilter.value) return false;
|
||||
if (authFilter.value && event.auth_status !== authFilter.value) return false;
|
||||
if (!token) return true;
|
||||
return [
|
||||
event.client_ip,
|
||||
formatIpLocation(event),
|
||||
event.account_label,
|
||||
event.path,
|
||||
resolveEndpointType(event.path),
|
||||
event.user_agent,
|
||||
event.auth_status,
|
||||
event.status_code,
|
||||
].some((value) => String(value || "").toLowerCase().includes(token));
|
||||
});
|
||||
});
|
||||
|
||||
const securityTotal = computed(() => detailFilteredEvents.value.length);
|
||||
|
||||
const paginatedEvents = computed(() => {
|
||||
const start = (page.value - 1) * pageSize.value;
|
||||
return detailFilteredEvents.value.slice(start, start + pageSize.value);
|
||||
});
|
||||
|
||||
const countByCategory = (category: string) => statsEvents.value.filter((event) => event.category === category).length;
|
||||
|
||||
const metricCards = computed<Array<{ label: string; value: string; tone: MetricTone; icon: any }>>(() => {
|
||||
const uniqueIpCount = new Set(statsEvents.value.map((event) => event.client_ip || "未知 IP")).size;
|
||||
const highRiskCount = statsEvents.value.filter(isHighRisk).length;
|
||||
const serverErrorCount = countByCategory("SERVER_ERROR");
|
||||
return [
|
||||
{ label: "安全事件", value: formatNumber(statsEvents.value.length), tone: "blue", icon: MetricIconShield },
|
||||
{ label: "异常 IP", value: formatNumber(uniqueIpCount), tone: "cyan", icon: MetricIconIp },
|
||||
{ label: "高危风险", value: formatNumber(highRiskCount), tone: highRiskCount > 0 ? "danger" : "indigo", icon: MetricIconAlert },
|
||||
{ label: "服务异常", value: formatNumber(serverErrorCount), tone: serverErrorCount > 0 ? "danger" : "indigo", icon: MetricIconServer },
|
||||
];
|
||||
});
|
||||
|
||||
const ipRiskStats = computed(() => {
|
||||
const stats = new Map<string, { ip: string; location: string; categoryText: string; total: number; highRiskCount: number; lastSeenAt: string }>();
|
||||
statsEvents.value.forEach((event) => {
|
||||
const ip = event.client_ip || "未知 IP";
|
||||
const current = stats.get(ip) || {
|
||||
ip,
|
||||
location: formatIpLocation(event),
|
||||
categoryText: categoryLabel(event.category),
|
||||
total: 0,
|
||||
highRiskCount: 0,
|
||||
lastSeenAt: event.created_at,
|
||||
};
|
||||
current.total += 1;
|
||||
if (isHighRisk(event)) current.highRiskCount += 1;
|
||||
if (new Date(event.created_at).getTime() > new Date(current.lastSeenAt).getTime()) {
|
||||
current.lastSeenAt = event.created_at;
|
||||
current.categoryText = categoryLabel(event.category);
|
||||
}
|
||||
stats.set(ip, current);
|
||||
});
|
||||
return [...stats.values()].sort((a, b) => b.highRiskCount - a.highRiskCount || b.total - a.total).slice(0, 10);
|
||||
});
|
||||
|
||||
const endpointTypeStats = computed(() => {
|
||||
const stats = new Map<string, { type: string; total: number; samplePath: string }>();
|
||||
statsEvents.value.forEach((event) => {
|
||||
const type = resolveEndpointType(event.path);
|
||||
const current = stats.get(type) || { type, total: 0, samplePath: event.path };
|
||||
current.total += 1;
|
||||
stats.set(type, current);
|
||||
});
|
||||
const maxTotal = Math.max(1, ...[...stats.values()].map((item) => item.total));
|
||||
return [...stats.values()]
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.map((item) => ({ ...item, percent: Math.max(8, Math.round((item.total / maxTotal) * 100)) }));
|
||||
});
|
||||
|
||||
const riskLevelStats = computed(() => {
|
||||
const filteredCounts = severityOptions.map((item) => ({
|
||||
...item,
|
||||
total: statsEvents.value.filter((event) => event.severity === item.value).length,
|
||||
}));
|
||||
const maxTotal = Math.max(1, ...filteredCounts.map((item) => item.total));
|
||||
return filteredCounts.map((item) => {
|
||||
return {
|
||||
...item,
|
||||
percent: item.total > 0 ? Math.max(8, Math.round((item.total / maxTotal) * 100)) : 0,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const SECURITY_STATS_PAGE_SIZE = 200;
|
||||
|
||||
const loadSecurityEvents = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
await loadSecurityStats();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadSecurityStats = async () => {
|
||||
const first = await fetchSecurityAccessLogs({
|
||||
status_min: 400,
|
||||
auth_status: authFilter.value || undefined,
|
||||
page: 1,
|
||||
page_size: SECURITY_STATS_PAGE_SIZE,
|
||||
});
|
||||
const allItems = [...first.data.items];
|
||||
const total = first.data.total;
|
||||
const totalPages = Math.ceil(total / SECURITY_STATS_PAGE_SIZE);
|
||||
|
||||
for (let nextPage = 2; nextPage <= totalPages; nextPage += 1) {
|
||||
const res = await fetchSecurityAccessLogs({
|
||||
status_min: 400,
|
||||
auth_status: authFilter.value || undefined,
|
||||
page: nextPage,
|
||||
page_size: SECURITY_STATS_PAGE_SIZE,
|
||||
});
|
||||
allItems.push(...res.data.items);
|
||||
}
|
||||
statsEvents.value = allItems;
|
||||
};
|
||||
|
||||
const onFilterChange = () => {
|
||||
page.value = 1;
|
||||
loadSecurityEvents();
|
||||
};
|
||||
|
||||
const onPageSizeChange = (size: number) => {
|
||||
pageSize.value = size;
|
||||
page.value = 1;
|
||||
};
|
||||
|
||||
watch(keyword, () => {
|
||||
page.value = 1;
|
||||
});
|
||||
|
||||
const openDetail = (event: SecurityAccessLogItem) => {
|
||||
selectedEvent.value = event;
|
||||
detailVisible.value = true;
|
||||
};
|
||||
|
||||
onMounted(loadSecurityEvents);
|
||||
|
||||
defineExpose({ refresh: loadSecurityEvents });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.security-center {
|
||||
--audit-ink: #1a2332;
|
||||
--audit-muted: #64748b;
|
||||
--audit-border: #e2e8f0;
|
||||
--audit-blue: #3b82f6;
|
||||
--audit-cyan: #06b6d4;
|
||||
--audit-danger: #ef4444;
|
||||
--audit-indigo: #6366f1;
|
||||
--card-radius: 16px;
|
||||
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.audit-filters {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
padding: 14px 18px;
|
||||
background: #fff;
|
||||
border-radius: var(--card-radius);
|
||||
border: 1px solid var(--audit-border);
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.security-search {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.audit-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-height: 64px;
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--card-radius);
|
||||
background: #fff;
|
||||
border: 1px solid var(--audit-border);
|
||||
box-shadow: var(--card-shadow);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.metric-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.metric-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
border-radius: var(--card-radius) var(--card-radius) 0 0;
|
||||
}
|
||||
|
||||
.metric-card.blue::before { background: linear-gradient(90deg, #3b82f6, #60a5fa); }
|
||||
.metric-card.cyan::before { background: linear-gradient(90deg, #06b6d4, #22d3ee); }
|
||||
.metric-card.danger::before { background: linear-gradient(90deg, #ef4444, #f87171); }
|
||||
.metric-card.indigo::before { background: linear-gradient(90deg, #6366f1, #818cf8); }
|
||||
|
||||
.metric-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.metric-icon svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.metric-card.blue .metric-icon { background: #eff6ff; color: #3b82f6; }
|
||||
.metric-card.cyan .metric-icon { background: #ecfeff; color: #06b6d4; }
|
||||
.metric-card.danger .metric-icon { background: #fef2f2; color: #ef4444; }
|
||||
.metric-card.indigo .metric-icon { background: #eef2ff; color: #6366f1; }
|
||||
|
||||
.metric-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
color: var(--audit-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
color: var(--audit-ink);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.audit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 1fr) minmax(0, 1.35fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.ranking-card,
|
||||
.security-table-wrap {
|
||||
min-width: 0;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--audit-border);
|
||||
border-radius: var(--card-radius);
|
||||
background: #fff;
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.table-head {
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.table-head .section-title-group {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.table-head .el-tag {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail-filters {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.section-title-group h4 {
|
||||
margin: 0;
|
||||
color: var(--audit-ink);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-title-group p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--audit-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.risk-rank-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.risk-rank-row {
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 12px 10px;
|
||||
border-radius: 10px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.risk-rank-row:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.rank-no {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
background: #f1f5f9;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rank-no.rank-top {
|
||||
background: linear-gradient(135deg, #ef4444, #6366f1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.rank-user,
|
||||
.rank-count {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rank-main-line,
|
||||
.rank-meta-line {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rank-main-line strong {
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
color: var(--audit-ink);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rank-main-line small {
|
||||
overflow: hidden;
|
||||
color: var(--audit-muted);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rank-meta-line span {
|
||||
color: var(--audit-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rank-count {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.rank-count strong {
|
||||
color: var(--audit-ink);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rank-count small {
|
||||
color: var(--audit-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.rank-count .denied-highlight {
|
||||
color: var(--audit-danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.distribution-list,
|
||||
.risk-level-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.event-distribution-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.distribution-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.distribution-section + .distribution-section {
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--audit-border);
|
||||
}
|
||||
|
||||
.event-distribution-card > .section-head {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.event-distribution-card .section-title-group h4 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.distribution-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.distribution-section-head strong {
|
||||
color: var(--audit-ink);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.distribution-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(160px, 0.9fr) minmax(120px, 1fr) 44px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.distribution-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.distribution-info strong {
|
||||
display: block;
|
||||
color: var(--audit-ink);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.distribution-info small {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
margin-top: 3px;
|
||||
color: var(--audit-muted);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.distribution-meter,
|
||||
.risk-level-meter {
|
||||
overflow: hidden;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.distribution-meter span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #06b6d4, #3b82f6);
|
||||
}
|
||||
|
||||
.distribution-count {
|
||||
color: var(--audit-ink);
|
||||
font-size: 16px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.risk-level-row {
|
||||
display: grid;
|
||||
grid-template-columns: 10px 52px minmax(0, 1fr) 44px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.risk-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.risk-dot.critical,
|
||||
.risk-level-meter span.critical { background: #991b1b; }
|
||||
.risk-dot.high,
|
||||
.risk-level-meter span.high { background: #ef4444; }
|
||||
.risk-dot.medium,
|
||||
.risk-level-meter span.medium { background: #f59e0b; }
|
||||
.risk-dot.low,
|
||||
.risk-level-meter span.low { background: #64748b; }
|
||||
|
||||
.risk-level-row > span:nth-child(2) {
|
||||
color: var(--audit-ink);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.risk-level-meter span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.risk-level-row strong {
|
||||
color: var(--audit-ink);
|
||||
font-size: 14px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.security-table-wrap {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
padding: 8px 16px 0;
|
||||
}
|
||||
|
||||
.category-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 112px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
background: #eef2f7;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.request-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.request-cell strong {
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.request-cell span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--audit-ink);
|
||||
}
|
||||
|
||||
.security-detail-head {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.security-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.detail-grid div,
|
||||
.detail-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--audit-border);
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.detail-grid span,
|
||||
.detail-section span {
|
||||
color: var(--audit-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-grid strong {
|
||||
color: var(--audit-ink);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.detail-section code {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: var(--audit-ink);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.audit-metrics,
|
||||
.audit-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.audit-metrics,
|
||||
.audit-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.security-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.distribution-row {
|
||||
grid-template-columns: 1fr 44px;
|
||||
}
|
||||
|
||||
.distribution-meter {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user