937 lines
28 KiB
Vue
937 lines
28 KiB
Vue
<template>
|
|
<div class="access-logs">
|
|
<section class="audit-filters">
|
|
<el-date-picker
|
|
v-model="dateRange"
|
|
type="datetimerange"
|
|
range-separator="至"
|
|
start-placeholder="开始时间"
|
|
end-placeholder="结束时间"
|
|
size="default"
|
|
style="width: 360px"
|
|
@change="onFilterChange"
|
|
/>
|
|
<el-select v-model="filters.role" placeholder="角色" clearable style="width: 130px" @change="onFilterChange">
|
|
<el-option v-for="role in roleFilterOptions" :key="role.value" :label="role.label" :value="role.value" />
|
|
</el-select>
|
|
<el-select v-model="filters.allowed" placeholder="结果" clearable style="width: 100px" @change="onFilterChange">
|
|
<el-option label="允许" :value="true" />
|
|
<el-option label="拒绝" :value="false" />
|
|
</el-select>
|
|
<el-input
|
|
v-model="keyword"
|
|
placeholder="搜索用户、IP、属地或接口"
|
|
clearable
|
|
style="width: 240px"
|
|
:prefix-icon="SearchIcon"
|
|
/>
|
|
</section>
|
|
|
|
<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>IP访问排行</h4>
|
|
</div>
|
|
<el-tag effect="plain" size="small" type="info">TOP {{ ipRankingItems.length }}</el-tag>
|
|
</div>
|
|
<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>
|
|
{{ 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>{{ item.total }}</strong>
|
|
<small :class="{ 'denied-highlight': item.riskCount > 0 }">{{ item.riskCount }} 异常/拒绝</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<el-empty v-else description="暂无用户行为数据" :image-size="72" />
|
|
</article>
|
|
|
|
<article class="log-launcher-card">
|
|
<div class="section-head">
|
|
<div class="section-title-group">
|
|
<h4>日志审计</h4>
|
|
</div>
|
|
</div>
|
|
<div class="log-actions">
|
|
<div class="log-action-item log-action-interface" @click="openInterfaceLogDialog">
|
|
<div class="log-action-icon">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
|
</div>
|
|
<div class="log-action-content">
|
|
<strong>接口访问审计日志</strong>
|
|
<span>记录所有 API 接口的访问行为</span>
|
|
</div>
|
|
<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">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
|
</div>
|
|
<div class="log-action-content">
|
|
<strong>安全事件审计日志</strong>
|
|
<span>异常访问与安全威胁事件</span>
|
|
</div>
|
|
<el-tag v-if="securitySummary.error_count > 0" effect="dark" size="small" round type="danger">{{ securitySummary.error_count }} 异常</el-tag>
|
|
<el-tag v-else effect="plain" size="small" round type="success">正常</el-tag>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
</section>
|
|
|
|
<el-dialog v-model="interfaceLogDialogVisible" title="接口访问审计日志" width="82%" @opened="scrollTerminalToBottom">
|
|
<template #header>
|
|
<div class="dialog-head">
|
|
<strong>接口访问审计日志</strong>
|
|
<div class="dialog-actions">
|
|
<el-tag effect="plain" type="info">共 {{ formatNumber(total) }} 条</el-tag>
|
|
<el-button size="small" type="primary" @click="downloadInterfaceLog">下载日志</el-button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<div ref="terminalWindowRef" v-loading="loading" class="terminal-window dialog-terminal-window">
|
|
<pre v-if="terminalLines.length" class="terminal-lines"><code>{{ terminalLines.join("\n") }}</code></pre>
|
|
<el-empty v-else description="暂无访问流水" :image-size="72" />
|
|
</div>
|
|
<div class="logs-pagination">
|
|
<el-pagination
|
|
v-model:current-page="page"
|
|
:page-size="pageSize"
|
|
:total="total"
|
|
layout="total, prev, pager, next"
|
|
@current-change="loadData"
|
|
/>
|
|
</div>
|
|
</el-dialog>
|
|
|
|
<el-dialog v-model="securityLogDialogVisible" title="安全事件审计日志" width="82%" @opened="scrollSecurityTerminalToBottom">
|
|
<template #header>
|
|
<div class="dialog-head">
|
|
<strong>安全事件审计日志</strong>
|
|
<div class="dialog-actions">
|
|
<el-tag effect="plain" type="danger">异常 {{ securitySummary.error_count }}</el-tag>
|
|
<el-button size="small" type="primary" @click="downloadSecurityLog">下载日志</el-button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<div ref="securityTerminalWindowRef" v-loading="securityLoading" class="terminal-window dialog-terminal-window">
|
|
<pre v-if="securityTerminalLines.length" class="terminal-lines"><code>{{ securityTerminalLines.join("\n") }}</code></pre>
|
|
<el-empty v-else description="暂无异常安全访问" :image-size="72" />
|
|
</div>
|
|
</el-dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, h, nextTick, onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
|
import { fetchAccessLogs, fetchSecurityAccessLogs } from "@/api/projectPermissions";
|
|
import type {
|
|
AccessLogItem,
|
|
AccessLogsSummary,
|
|
AccessLogUserStat,
|
|
SecurityAccessLogItem,
|
|
SecurityAccessLogsResponse,
|
|
} from "@/types/api";
|
|
import { Search as SearchIcon } from "@element-plus/icons-vue";
|
|
import { useRoleTemplateMeta } from "@/composables/useRoleTemplateMeta";
|
|
import { saveFileWithFeedback } from "@/utils/fileTaskFeedback";
|
|
|
|
const props = withDefaults(defineProps<{ showSecurityLog?: boolean }>(), {
|
|
showSecurityLog: false,
|
|
});
|
|
const showSecurityLog = computed(() => props.showSecurityLog);
|
|
const { roleLabel, roleOptionsFor, loadRoleTemplates } = useRoleTemplateMeta();
|
|
|
|
const MetricIconVisit = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
|
h("path", { d: "M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" }),
|
|
h("circle", { cx: "12", cy: "12", r: "3" }),
|
|
]);
|
|
const MetricIconUsers = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
|
h("path", { d: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" }),
|
|
h("circle", { cx: "9", cy: "7", r: "4" }),
|
|
h("path", { d: "M23 21v-2a4 4 0 0 0-3-3.87" }),
|
|
h("path", { d: "M16 3.13a4 4 0 0 1 0 7.75" }),
|
|
]);
|
|
const MetricIconDeny = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
|
h("circle", { cx: "12", cy: "12", r: "10" }),
|
|
h("line", { x1: "4.93", y1: "4.93", x2: "19.07", y2: "19.07" }),
|
|
]);
|
|
const MetricIconSpeed = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
|
h("circle", { cx: "12", cy: "12", r: "10" }),
|
|
h("polyline", { points: "12 6 12 12 16 14" }),
|
|
]);
|
|
|
|
const REALTIME_POLL_INTERVAL_MS = 3000;
|
|
const IP_RANKING_LIMIT = 10;
|
|
const SECURITY_RANKING_PAGE_SIZE = 200;
|
|
|
|
const emptySummary: AccessLogsSummary = {
|
|
total_count: 0,
|
|
unique_user_count: 0,
|
|
unique_ip_count: 0,
|
|
denied_count: 0,
|
|
avg_elapsed_ms: 0,
|
|
};
|
|
|
|
const emptySecuritySummary: SecurityAccessLogsResponse["summary"] = {
|
|
total_count: 0,
|
|
anonymous_count: 0,
|
|
invalid_token_count: 0,
|
|
error_count: 0,
|
|
};
|
|
|
|
const loading = ref(false);
|
|
const securityLoading = ref(false);
|
|
const logs = ref<AccessLogItem[]>([]);
|
|
const securityLogs = ref<SecurityAccessLogItem[]>([]);
|
|
const userStats = ref<AccessLogUserStat[]>([]);
|
|
const summary = ref<AccessLogsSummary>(emptySummary);
|
|
const securitySummary = ref<SecurityAccessLogsResponse["summary"]>(emptySecuritySummary);
|
|
const total = ref(0);
|
|
const page = ref(1);
|
|
const pageSize = 50;
|
|
const dateRange = ref<[Date, Date] | null>(null);
|
|
const keyword = ref("");
|
|
const interfaceLogDialogVisible = ref(false);
|
|
const securityLogDialogVisible = ref(false);
|
|
const terminalWindowRef = ref<HTMLElement | null>(null);
|
|
const securityTerminalWindowRef = ref<HTMLElement | null>(null);
|
|
const realtimeTimer = ref<number | null>(null);
|
|
|
|
const filters = reactive({
|
|
role: undefined as string | undefined,
|
|
allowed: undefined as boolean | undefined,
|
|
});
|
|
|
|
const ROLE_FILTER_KEYS = ["ADMIN", "PM", "CRA", "PV", "QA", "CTA"];
|
|
const roleFilterOptions = computed(() => roleOptionsFor(ROLE_FILTER_KEYS));
|
|
|
|
const SECURITY_AUTH_LABELS: Record<string, string> = {
|
|
ANONYMOUS: "匿名",
|
|
INVALID_TOKEN: "无效令牌",
|
|
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");
|
|
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
};
|
|
|
|
const formatIpLocation = (row: AccessLogItem) => {
|
|
const parts = [row.ip_province, row.ip_city, row.ip_isp].filter(Boolean);
|
|
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(() => [
|
|
{
|
|
label: "访问次数",
|
|
value: formatNumber(summary.value.total_count),
|
|
tone: "blue",
|
|
icon: MetricIconVisit,
|
|
},
|
|
{
|
|
label: "访问用户数",
|
|
value: formatNumber(summary.value.unique_user_count),
|
|
tone: "cyan",
|
|
icon: MetricIconUsers,
|
|
},
|
|
{
|
|
label: "拒绝次数",
|
|
value: formatNumber(summary.value.denied_count),
|
|
tone: summary.value.denied_count > 0 ? "danger" : "violet",
|
|
icon: MetricIconDeny,
|
|
},
|
|
{
|
|
label: "平均耗时",
|
|
value: `${summary.value.avg_elapsed_ms.toFixed(1)}ms`,
|
|
tone: "indigo",
|
|
icon: MetricIconSpeed,
|
|
},
|
|
]);
|
|
|
|
const buildTerminalLine = (row: AccessLogItem) => {
|
|
const result = row.allowed ? "ALLOW" : "DENY ";
|
|
const user = row.user_name || "未知用户";
|
|
const ip = row.ip_address || "-";
|
|
const location = formatIpLocation(row) || "未知属地";
|
|
return `${formatTerminalTime(row.created_at)} [${result}] role=${roleLabel(row.role)} user=${user} ip=${ip} loc=${location} endpoint=${row.endpoint_key} cost=${row.elapsed_ms.toFixed(1)}ms`;
|
|
};
|
|
|
|
const buildSecurityTerminalLine = (row: SecurityAccessLogItem) => {
|
|
const ip = row.client_ip || "未知 IP";
|
|
const account = row.account_label || "未知账号";
|
|
const auth = SECURITY_AUTH_LABELS[row.auth_status] || row.auth_status;
|
|
const userAgent = row.user_agent || "-";
|
|
const client = [row.client_type, row.client_version, row.client_platform].filter(Boolean).join("/") || "unknown";
|
|
return `${formatTerminalTime(row.created_at)} [SECURITY] status=${row.status_code} auth_status=${row.auth_status}/${auth} account_label=${account} client=${client} client_ip=${ip} method=${row.method} path=${row.path} ua=${userAgent} cost=${row.elapsed_ms.toFixed(1)}ms`;
|
|
};
|
|
|
|
const terminalLogRows = computed(() =>
|
|
[...logs.value].sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()),
|
|
);
|
|
|
|
const terminalLines = computed(() => {
|
|
const token = keyword.value.trim().toLowerCase();
|
|
const lines = terminalLogRows.value.map(buildTerminalLine);
|
|
if (!token) return lines;
|
|
return lines.filter((line) => line.toLowerCase().includes(token));
|
|
});
|
|
|
|
const securityTerminalLines = computed(() => {
|
|
const token = keyword.value.trim().toLowerCase();
|
|
const lines = [...securityLogs.value]
|
|
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
|
|
.map(buildSecurityTerminalLine);
|
|
if (!token) return lines;
|
|
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();
|
|
};
|
|
|
|
const buildQueryParams = () => {
|
|
const params: Record<string, any> = {
|
|
page: page.value,
|
|
page_size: pageSize,
|
|
};
|
|
if (filters.role) params.role = filters.role;
|
|
if (filters.allowed !== undefined) params.allowed = filters.allowed;
|
|
if (dateRange.value) {
|
|
params.start_time = dateRange.value[0].toISOString();
|
|
params.end_time = dateRange.value[1].toISOString();
|
|
}
|
|
return params;
|
|
};
|
|
|
|
const scrollTerminalToBottom = () => {
|
|
nextTick(() => {
|
|
const terminal = terminalWindowRef.value;
|
|
if (!terminal) return;
|
|
terminal.scrollTop = terminal.scrollHeight;
|
|
});
|
|
};
|
|
|
|
const scrollSecurityTerminalToBottom = () => {
|
|
nextTick(() => {
|
|
const terminal = securityTerminalWindowRef.value;
|
|
if (!terminal) return;
|
|
terminal.scrollTop = terminal.scrollHeight;
|
|
});
|
|
};
|
|
|
|
const loadData = async (options: { silent?: boolean } = {}) => {
|
|
if (!options.silent) loading.value = true;
|
|
try {
|
|
const res = await fetchAccessLogs(buildQueryParams());
|
|
logs.value = res.data.items;
|
|
total.value = res.data.total;
|
|
summary.value = res.data.summary || emptySummary;
|
|
userStats.value = res.data.user_stats || [];
|
|
if (interfaceLogDialogVisible.value) scrollTerminalToBottom();
|
|
} catch {
|
|
if (options.silent) return;
|
|
logs.value = [];
|
|
total.value = 0;
|
|
summary.value = emptySummary;
|
|
userStats.value = [];
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const loadSecurityData = async (options: { silent?: boolean } = {}) => {
|
|
if (!showSecurityLog.value) return;
|
|
if (!options.silent) securityLoading.value = true;
|
|
try {
|
|
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;
|
|
securityLogs.value = [];
|
|
securitySummary.value = emptySecuritySummary;
|
|
} finally {
|
|
securityLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const stopRealtimePolling = () => {
|
|
if (!realtimeTimer.value) return;
|
|
window.clearInterval(realtimeTimer.value);
|
|
realtimeTimer.value = null;
|
|
};
|
|
|
|
const startRealtimePolling = () => {
|
|
stopRealtimePolling();
|
|
realtimeTimer.value = window.setInterval(() => {
|
|
loadData({ silent: true });
|
|
if (showSecurityLog.value) loadSecurityData({ silent: true });
|
|
}, REALTIME_POLL_INTERVAL_MS);
|
|
};
|
|
|
|
const refresh = () => {
|
|
loadData();
|
|
if (showSecurityLog.value) loadSecurityData();
|
|
};
|
|
|
|
const openInterfaceLogDialog = () => {
|
|
interfaceLogDialogVisible.value = true;
|
|
loadData({ silent: true });
|
|
scrollTerminalToBottom();
|
|
};
|
|
|
|
const openSecurityLogDialog = () => {
|
|
if (!showSecurityLog.value) return;
|
|
securityLogDialogVisible.value = true;
|
|
loadSecurityData({ silent: true });
|
|
scrollSecurityTerminalToBottom();
|
|
};
|
|
|
|
const downloadLogFile = async (fileName: string, lines: string[]) => {
|
|
const blob = new Blob([`${lines.join("\n")}\n`], { type: "text/plain;charset=utf-8" });
|
|
await saveFileWithFeedback(
|
|
{ suggestedName: fileName, mimeType: "text/plain;charset=utf-8", data: blob },
|
|
{
|
|
kind: "export",
|
|
title: "导出日志",
|
|
completedDetail: "日志文件已保存",
|
|
},
|
|
);
|
|
};
|
|
|
|
const downloadInterfaceLog = () => {
|
|
downloadLogFile("interface-access-audit.log", terminalLines.value);
|
|
};
|
|
|
|
const downloadSecurityLog = () => {
|
|
downloadLogFile("security-event-audit.log", securityTerminalLines.value);
|
|
};
|
|
|
|
onMounted(async () => {
|
|
await loadRoleTemplates();
|
|
refresh();
|
|
startRealtimePolling();
|
|
});
|
|
|
|
onBeforeUnmount(stopRealtimePolling);
|
|
|
|
defineExpose({ refresh });
|
|
</script>
|
|
|
|
<style scoped>
|
|
.access-logs {
|
|
--audit-ink: #1a2332;
|
|
--audit-muted: #64748b;
|
|
--audit-border: #e2e8f0;
|
|
--audit-blue: #3b82f6;
|
|
--audit-cyan: #06b6d4;
|
|
--audit-violet: #8b5cf6;
|
|
--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);
|
|
}
|
|
|
|
.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.violet::before { background: linear-gradient(90deg, #8b5cf6, #a78bfa); }
|
|
.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.violet .metric-icon { background: #f5f3ff; color: #8b5cf6; }
|
|
.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,
|
|
.log-launcher-card {
|
|
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;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.user-rank-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
|
|
.user-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;
|
|
}
|
|
|
|
.user-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, #3b82f6, #6366f1);
|
|
color: #fff;
|
|
}
|
|
|
|
.rank-user,
|
|
.rank-count {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 3px;
|
|
}
|
|
|
|
.rank-user strong {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
min-width: 0;
|
|
color: var(--audit-ink);
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.rank-user small {
|
|
color: var(--audit-muted);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.rank-location {
|
|
color: #94a3b8;
|
|
font-size: 11px;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.abnormal-rank-row {
|
|
background: #fff7f7;
|
|
}
|
|
|
|
.rank-risk-tag {
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.log-actions {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
|
|
.log-action-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 14px;
|
|
padding: 16px 18px;
|
|
border-radius: 12px;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.log-action-interface {
|
|
background: linear-gradient(135deg, #f0f9ff, #e0f2fe);
|
|
border: 1px solid #bae6fd;
|
|
}
|
|
|
|
.log-action-interface:hover {
|
|
background: linear-gradient(135deg, #e0f2fe, #bae6fd);
|
|
box-shadow: 0 4px 12px rgba(14, 165, 233, 0.12);
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.log-action-security {
|
|
background: linear-gradient(135deg, #fef2f2, #fee2e2);
|
|
border: 1px solid #fecaca;
|
|
}
|
|
|
|
.log-action-security:hover {
|
|
background: linear-gradient(135deg, #fee2e2, #fecaca);
|
|
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.12);
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.log-action-icon {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 10px;
|
|
background: #fff;
|
|
flex-shrink: 0;
|
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
|
}
|
|
|
|
.log-action-icon svg {
|
|
width: 20px;
|
|
height: 20px;
|
|
color: #0ea5e9;
|
|
}
|
|
|
|
.log-action-icon.security svg {
|
|
color: #ef4444;
|
|
}
|
|
|
|
.log-action-content {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.log-action-content strong {
|
|
display: block;
|
|
color: var(--audit-ink);
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.log-action-content span {
|
|
display: block;
|
|
margin-top: 2px;
|
|
color: var(--audit-muted);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.dialog-head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
width: 100%;
|
|
}
|
|
|
|
.dialog-head strong {
|
|
color: var(--audit-ink);
|
|
font-size: 17px;
|
|
}
|
|
|
|
.dialog-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.terminal-window {
|
|
min-height: 360px;
|
|
max-height: 520px;
|
|
overflow: auto;
|
|
padding: 16px;
|
|
border: 1px solid #1e293b;
|
|
border-radius: 12px;
|
|
background: linear-gradient(180deg, #1e293b, #0f172a);
|
|
}
|
|
|
|
.dialog-terminal-window {
|
|
height: 62vh;
|
|
min-height: 360px;
|
|
max-height: 68vh;
|
|
}
|
|
|
|
.terminal-lines {
|
|
margin: 0;
|
|
color: #e2e8f0;
|
|
font-family: "JetBrains Mono", "SFMono-Regular", "Cascadia Code", "Menlo", monospace;
|
|
font-size: 12.5px;
|
|
line-height: 1.9;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
}
|
|
|
|
.logs-pagination {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
margin-top: 12px;
|
|
}
|
|
|
|
@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;
|
|
}
|
|
}
|
|
</style>
|