完善权限监控与安全中心

This commit is contained in:
Cheng Zhou
2026-06-17 17:21:48 +08:00
parent 1886765db8
commit 061792c73f
15 changed files with 2837 additions and 160 deletions
+139 -22
View File
@@ -44,21 +44,23 @@
<div class="section-head">
<div class="section-title-group">
<h4>IP访问排行</h4>
<p>按来源 IP 排序账号作为辅助定位</p>
</div>
<el-tag effect="plain" size="small" type="info">TOP {{ userStats.length }}</el-tag>
<el-tag effect="plain" size="small" type="info">TOP {{ ipRankingItems.length }}</el-tag>
</div>
<div v-if="userStats.length" class="user-rank-list">
<div v-for="(user, index) in userStats" :key="`${user.sample_ip_address}-${user.user_id}-${user.role}`" class="user-rank-row">
<div v-if="ipRankingItems.length" class="user-rank-list">
<div v-for="(item, index) in ipRankingItems" :key="item.ip" class="user-rank-row" :class="{ 'abnormal-rank-row': item.isAbnormal }">
<span class="rank-no" :class="{ 'rank-top': index < 3 }">{{ String(index + 1).padStart(2, "0") }}</span>
<div class="rank-user">
<strong>{{ user.sample_ip_address || "未知 IP" }}</strong>
<small>{{ user.user_name }} / {{ roleLabel(user.role) }}</small>
<span class="rank-location">{{ user.primary_location || "未知属地" }}</span>
<strong>
{{ item.ip }}
<el-tag v-if="item.isAbnormal" class="rank-risk-tag" effect="plain" size="small" type="danger">异常IP</el-tag>
</strong>
<small>{{ item.label }}</small>
<span class="rank-location">{{ item.location }}</span>
</div>
<div class="rank-count">
<strong>{{ user.total_count }}</strong>
<small :class="{ 'denied-highlight': user.denied_count > 0 }">{{ user.denied_count }} 拒绝</small>
<strong>{{ item.total }}</strong>
<small :class="{ 'denied-highlight': item.riskCount > 0 }">{{ item.riskCount }} 异常/拒绝</small>
</div>
</div>
</div>
@@ -69,7 +71,6 @@
<div class="section-head">
<div class="section-title-group">
<h4>日志审计</h4>
<p>查看详细的接口访问与安全事件记录</p>
</div>
</div>
<div class="log-actions">
@@ -81,7 +82,7 @@
<strong>接口访问审计日志</strong>
<span>记录所有 API 接口的访问行为</span>
</div>
<el-tag effect="dark" size="small" round>{{ terminalLines.length }} </el-tag>
<el-tag effect="dark" size="small" round>{{ formatNumber(total) }} </el-tag>
</div>
<div v-if="showSecurityLog" class="log-action-item log-action-security" @click="openSecurityLogDialog">
<div class="log-action-icon security">
@@ -103,7 +104,7 @@
<div class="dialog-head">
<strong>接口访问审计日志</strong>
<div class="dialog-actions">
<el-tag effect="plain" type="info">最近 {{ terminalLines.length }} </el-tag>
<el-tag effect="plain" type="info"> {{ formatNumber(total) }} </el-tag>
<el-button size="small" type="primary" @click="downloadInterfaceLog">下载日志</el-button>
</div>
</div>
@@ -180,6 +181,8 @@ const MetricIconSpeed = () => h("svg", { viewBox: "0 0 24 24", fill: "none", str
]);
const REALTIME_POLL_INTERVAL_MS = 3000;
const IP_RANKING_LIMIT = 10;
const SECURITY_RANKING_PAGE_SIZE = 200;
const emptySummary: AccessLogsSummary = {
total_count: 0,
@@ -228,6 +231,26 @@ const SECURITY_AUTH_LABELS: Record<string, string> = {
AUTHENTICATED: "已认证",
};
const SECURITY_CATEGORY_LABELS: Record<string, string> = {
ABNORMAL_IP: "异常IP",
PROBE: "敏感路径探测",
INVALID_TOKEN: "无效令牌",
SERVER_ERROR: "服务异常",
ANONYMOUS_API: "匿名 API",
NOT_FOUND_NOISE: "普通 404",
OTHER: "其他",
};
interface IpRankingItem {
ip: string;
location: string;
label: string;
total: number;
riskCount: number;
isAbnormal: boolean;
lastSeenAt: string | null;
}
const formatTerminalTime = (iso: string) => {
const date = new Date(iso);
const pad = (value: number) => String(value).padStart(2, "0");
@@ -239,6 +262,13 @@ const formatIpLocation = (row: AccessLogItem) => {
return parts.length ? parts.join(" / ") : row.ip_location;
};
const formatSecurityIpLocation = (row: SecurityAccessLogItem) => {
const parts = [row.ip_country && row.ip_country !== "中国" ? row.ip_country : "", row.ip_province, row.ip_city, row.ip_isp].filter(Boolean);
return parts.length ? parts.join(" / ") : row.ip_location;
};
const categoryLabel = (category: string) => SECURITY_CATEGORY_LABELS[category] || category || "其他";
const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
const metricCards = computed(() => [
@@ -304,6 +334,77 @@ const securityTerminalLines = computed(() => {
return lines.filter((line) => line.toLowerCase().includes(token));
});
const ipRankingItems = computed<IpRankingItem[]>(() => {
const rankingByIp = new Map<string, IpRankingItem>();
userStats.value.forEach((user) => {
const ip = user.sample_ip_address || "未知 IP";
const existing = rankingByIp.get(ip);
const lastSeenAt =
existing?.lastSeenAt && new Date(existing.lastSeenAt).getTime() > new Date(user.last_seen_at || 0).getTime()
? existing.lastSeenAt
: user.last_seen_at;
if (!existing) {
rankingByIp.set(ip, {
ip,
location: user.primary_location || "未知属地",
label: `${user.user_name || "未知用户"} / ${roleLabel(user.role)}`,
total: user.total_count,
riskCount: user.denied_count,
isAbnormal: false,
lastSeenAt,
});
return;
}
existing.total += user.total_count;
existing.riskCount += user.denied_count;
existing.lastSeenAt = lastSeenAt;
if (existing.location === "未知属地" && user.primary_location) existing.location = user.primary_location;
});
securityLogs.value.forEach((event) => {
const ip = event.client_ip || "未知 IP";
const existing = rankingByIp.get(ip);
const isAbnormal = event.category === "ABNORMAL_IP";
const location = formatSecurityIpLocation(event) || existing?.location || "未知属地";
const lastSeenAt =
existing?.lastSeenAt && new Date(existing.lastSeenAt).getTime() > new Date(event.created_at).getTime()
? existing.lastSeenAt
: event.created_at;
if (!existing) {
rankingByIp.set(ip, {
ip,
location,
label: isAbnormal ? categoryLabel(event.category) : `${categoryLabel(event.category)} / ${event.account_label || "未知账号"}`,
total: 1,
riskCount: isAbnormal || event.severity === "HIGH" || event.severity === "CRITICAL" ? 1 : 0,
isAbnormal,
lastSeenAt,
});
return;
}
existing.location = location;
existing.total += 1;
existing.riskCount += isAbnormal || event.severity === "HIGH" || event.severity === "CRITICAL" ? 1 : 0;
existing.isAbnormal = existing.isAbnormal || isAbnormal;
existing.lastSeenAt = lastSeenAt;
if (isAbnormal) existing.label = categoryLabel(event.category);
});
return [...rankingByIp.values()]
.sort((a, b) => {
if (b.total !== a.total) return b.total - a.total;
if (b.riskCount !== a.riskCount) return b.riskCount - a.riskCount;
if (b.isAbnormal !== a.isAbnormal) return Number(b.isAbnormal) - Number(a.isAbnormal);
return new Date(b.lastSeenAt || 0).getTime() - new Date(a.lastSeenAt || 0).getTime();
})
.slice(0, IP_RANKING_LIMIT);
});
const onFilterChange = () => {
page.value = 1;
loadData();
@@ -363,9 +464,15 @@ const loadSecurityData = async (options: { silent?: boolean } = {}) => {
if (!showSecurityLog.value) return;
if (!options.silent) securityLoading.value = true;
try {
const res = await fetchSecurityAccessLogs({ status_min: 400, page: 1, page_size: 80 });
securityLogs.value = res.data.items;
securitySummary.value = res.data.summary || emptySecuritySummary;
const first = await fetchSecurityAccessLogs({ status_min: 400, page: 1, page_size: SECURITY_RANKING_PAGE_SIZE });
const allItems = [...first.data.items];
const totalPages = Math.ceil(first.data.total / SECURITY_RANKING_PAGE_SIZE);
for (let nextPage = 2; nextPage <= totalPages; nextPage += 1) {
const res = await fetchSecurityAccessLogs({ status_min: 400, page: nextPage, page_size: SECURITY_RANKING_PAGE_SIZE });
allItems.push(...res.data.items);
}
securityLogs.value = allItems;
securitySummary.value = first.data.summary || emptySecuritySummary;
if (securityLogDialogVisible.value) scrollSecurityTerminalToBottom();
} catch {
if (options.silent) return;
@@ -551,7 +658,7 @@ defineExpose({ refresh });
.audit-grid {
display: grid;
grid-template-columns: 380px minmax(0, 1fr);
grid-template-columns: minmax(320px, 1fr) minmax(0, 1.35fr);
gap: 14px;
}
@@ -632,6 +739,10 @@ defineExpose({ refresh });
}
.rank-user strong {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
color: var(--audit-ink);
font-size: 14px;
font-weight: 600;
@@ -667,6 +778,14 @@ defineExpose({ refresh });
font-weight: 600;
}
.abnormal-rank-row {
background: #fff7f7;
}
.rank-risk-tag {
flex-shrink: 0;
}
.log-actions {
display: flex;
flex-direction: column;
@@ -798,17 +917,15 @@ defineExpose({ refresh });
}
@media (max-width: 1100px) {
.audit-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.audit-metrics,
.audit-grid {
grid-template-columns: 1fr;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.audit-metrics {
.audit-metrics,
.audit-grid {
grid-template-columns: 1fr;
}
}