Files
ctms/frontend/src/components/PermissionAccessLogs.vue
T
Cheng Zhou ab59476d10
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
feat(桌面与监控): 完善工作台导航和登录活动定位
- 优化桌面标签、上下文标题、前进后退、导航栏隐藏和原生菜单体验

- 补充登录会话 IP 采集、地理位置回退、管理端展示及数据库迁移

- 更新桌面发布检查、运维文档和前后端测试覆盖
2026-07-13 16:03:20 +08:00

1894 lines
56 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="access-logs">
<section class="access-overview">
<section class="access-toolbar">
<div class="access-toolbar-title">
<h3>访问日志</h3>
</div>
<div class="access-toolbar-actions">
<TimeRangeSelector v-model="timeRangePreset" @change="onTimePresetChange" />
<span v-if="lastUpdatedAt" class="ctms-updated-at">更新于 {{ formatLastUpdated(lastUpdatedAt) }}</span>
<el-button size="small" type="primary" :loading="loading" @click="loadData">刷新</el-button>
</div>
</section>
<section class="audit-metrics" aria-label="访问指标">
<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>
<el-alert v-if="loadError" :title="loadError" type="warning" show-icon :closable="false" class="load-alert" />
<section class="access-log-card">
<div class="audit-filters">
<div class="filter-inputs">
<el-tag
v-if="trendDrilldownActive"
closable
effect="plain"
size="small"
type="primary"
class="trend-drilldown-tag"
@close="clearTrendDrilldownRange"
>
权限趋势下钻{{ drilldownRangeLabel ? ` · ${drilldownRangeLabel}` : "" }}
</el-tag>
<el-select v-model="filters.role" placeholder="角色" clearable size="small" class="filter-role" @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 size="small" class="filter-result" @change="onFilterChange">
<el-option label="允许" :value="true" />
<el-option label="拒绝" :value="false" />
</el-select>
<el-select v-model="filters.clientType" placeholder="来源" clearable size="small" class="filter-source" @change="onFilterChange">
<el-option v-for="source in clientSourceOptions" :key="source.value" :label="source.label" :value="source.value" />
</el-select>
<el-input v-model="filters.keyword" placeholder="搜索账号、IP、属地、接口或来源" clearable size="small" class="filter-keyword" :prefix-icon="SearchIcon" @input="onKeywordInput" @clear="onFilterChange" @keyup.enter="onFilterChange" />
<el-button plain size="small" class="filter-reset" @click="resetFilters">重置</el-button>
</div>
<div class="table-actions">
<el-tag effect="plain" size="small" type="info"> {{ formatNumber(total) }} </el-tag>
<el-button size="small" class="ctms-btn-dialog ctms-btn-capsule-info" :loading="ipRankingLoading" @click="openIpRankingDialog">
IP访问排行
<el-icon class="el-icon--right"><HistogramIcon /></el-icon>
</el-button>
<el-button size="small" class="ctms-btn-dialog ctms-btn-gradient-blue" :loading="accessLogDialogLoading" @click="openAccessLogDialog">
访问日志
<el-icon class="el-icon--right"><DocumentIcon /></el-icon>
</el-button>
</div>
</div>
<div class="log-surface">
<div class="table-shell">
<el-table v-loading="loading" :data="logs" class="access-table" size="small" table-layout="fixed">
<el-table-column label="时间" width="160">
<template #default="{ row }">
<span class="time-cell">{{ formatTerminalTime(row.created_at) }}</span>
</template>
</el-table-column>
<el-table-column label="IP" width="120">
<template #default="{ row }">
<span class="ip-cell">{{ row.ip_address || "未知 IP" }}</span>
</template>
</el-table-column>
<el-table-column label="位置" width="110">
<template #default="{ row }">
<span class="location-cell">{{ formatIpLocation(row) || "未知位置" }}</span>
</template>
</el-table-column>
<el-table-column label="账号" min-width="160">
<template #default="{ row }">
<div class="account-cell">
<strong>{{ row.user_name || "未知账号" }}</strong>
<span>{{ accountSecondary(row) }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="请求" min-width="380">
<template #default="{ row }">
<div class="request-cell">
<strong>
<el-tag :type="isSecurityLog(row) ? 'danger' : 'info'" effect="plain" size="small">{{ eventTypeLabel(row) }}</el-tag>
<span>{{ requestMain(row) }}</span>
</strong>
<span>{{ requestSub(row) }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="耗时" width="100">
<template #default="{ row }">
<div class="elapsed-cell">
<strong>{{ row.elapsed_ms.toFixed(1) }}ms</strong>
<el-tag :type="accessResultTagType(row)" effect="plain" size="small">{{ accessResultLabel(row) }}</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="64" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openLogDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<div class="logs-pagination">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[50, 100, 200]"
:total="total"
small
layout="total, sizes, prev, pager, next"
@size-change="onPageSizeChange"
@current-change="loadData"
/>
</div>
</div>
</div>
</section>
<el-drawer v-model="detailDrawerVisible" direction="rtl" size="520px" class="access-detail-drawer" :show-close="false">
<template #header>
<div class="dialog-head">
<strong>访问详情</strong>
<div class="dialog-actions">
<el-button size="small" @click="detailDrawerVisible = false">关闭</el-button>
</div>
</div>
</template>
<div v-if="selectedLog" class="detail-stack">
<div class="detail-grid">
<div class="detail-card">
<span>账号</span>
<strong>{{ selectedLog.user_name || "未知用户" }}</strong>
<small>{{ selectedLog.user_email || selectedLog.user_id }}</small>
</div>
<div class="detail-card">
<span>来源</span>
<strong>{{ clientSourceLabel(selectedLog) }}</strong>
<small>{{ clientMeta(selectedLog) }}</small>
</div>
<div class="detail-card">
<span>IP / 属地</span>
<strong>{{ selectedLog.ip_address || "未知 IP" }}</strong>
<small>{{ formatIpLocation(selectedLog) || "未知属地" }}</small>
</div>
<div class="detail-card">
<span>结果 / 耗时</span>
<strong>{{ accessResultLabel(selectedLog) }}</strong>
<small>{{ selectedLog.elapsed_ms.toFixed(1) }}ms</small>
</div>
</div>
<div class="detail-section detail-section--request">
<h4>请求概览</h4>
<p class="detail-request-line">{{ requestSummaryValue(selectedLog) }}</p>
<div class="detail-field-grid">
<div v-for="field in requestOverviewFieldItems(selectedLog)" :key="field.label" class="detail-field-item">
<span>{{ field.label }}</span>
<strong>{{ field.value }}</strong>
</div>
</div>
</div>
<div class="detail-section detail-section--raw">
<h4>原始 HTTP 请求</h4>
<pre class="detail-raw-line">{{ buildRawRequestBlock(selectedLog) }}</pre>
</div>
<el-collapse class="detail-more">
<el-collapse-item name="metadata" title="更多审计信息">
<div class="detail-subsection">
<h5>日志标识</h5>
<div class="detail-field-grid">
<div v-for="field in auditMetadataFieldItems(selectedLog)" :key="field.label" class="detail-field-item">
<span>{{ field.label }}</span>
<strong>{{ field.value }}</strong>
</div>
</div>
</div>
<div class="detail-subsection">
<h5>传输信息</h5>
<div class="detail-field-grid">
<div v-for="field in requestSnapshotFieldItems(selectedLog)" :key="field.label" class="detail-field-item">
<span>{{ field.label }}</span>
<strong>{{ field.value }}</strong>
</div>
</div>
</div>
<div class="detail-subsection">
<h5>客户端标识</h5>
<div class="detail-field-grid">
<div class="detail-field-item">
<span>User-Agent</span>
<strong>{{ selectedLog.user_agent || "未记录" }}</strong>
</div>
<div class="detail-field-item">
<span>构建信息</span>
<strong>{{ buildMeta(selectedLog) }}</strong>
</div>
</div>
</div>
</el-collapse-item>
</el-collapse>
</div>
</el-drawer>
<el-dialog v-model="accessLogDialogVisible" title="访问日志" width="82%" @opened="scrollAccessTerminalToBottom">
<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" :loading="rawExportLoading" @click="downloadInterfaceLog">下载日志</el-button>
</div>
</div>
</template>
<div ref="accessTerminalWindowRef" v-loading="accessLogDialogLoading" class="terminal-window dialog-terminal-window">
<el-alert v-if="accessDialogError" :title="accessDialogError" type="warning" show-icon :closable="false" />
<pre v-if="accessDialogLines.length" class="terminal-lines"><code>{{ accessDialogLines.join("\n") }}</code></pre>
<el-empty v-else description="暂无原始访问日志" :image-size="72" />
</div>
</el-dialog>
<el-dialog v-model="ipRankingDialogVisible" title="IP访问排行" width="min(1180px, 96vw)">
<template #header>
<div class="dialog-head">
<strong>IP访问排行</strong>
<div class="dialog-actions">
<TimeRangeSelector v-model="ipRankingPeriod" class="ranking-period-cards" @change="selectIpRankingPeriod" />
<el-tag effect="plain" :type="ipRankingSummary.riskCount > 0 ? 'danger' : 'success'">
风险 {{ formatNumber(ipRankingSummary.riskCount) }}
</el-tag>
</div>
</div>
</template>
<div v-loading="ipRankingLoading" class="ip-ranking-dialog">
<el-alert v-if="ipRankingError" :title="ipRankingError" type="warning" show-icon :closable="false" />
<div class="ip-ranking-list-head">
<el-tag effect="plain" size="small" type="info">TOP {{ ipRankingRows.length }}</el-tag>
<span>
访问 {{ formatNumber(ipRankingSummary.totalCount) }} · 风险 {{ formatNumber(ipRankingSummary.riskCount) }} · 高危 {{ formatNumber(ipRankingSummary.highRiskCount) }} · IP {{ formatNumber(ipRankingSummary.uniqueIpCount) }}
</span>
</div>
<div v-if="ipRankingRows.length" class="ip-rank-grid">
<article v-for="(row, index) in ipRankingRows" :key="row.ip" class="ip-rank-card" :class="riskRankCardClass(row)">
<div class="rank-card-head">
<span class="rank-no" :class="{ 'rank-top': index < 3 }">{{ String(index + 1).padStart(2, "0") }}</span>
<el-tag v-if="row.riskCount > 0" size="small" :type="row.riskTagType" effect="plain" class="rank-risk-tag">{{ row.riskLabel }}</el-tag>
</div>
<div class="rank-card-ip">
<strong>{{ row.ip }}</strong>
<span>{{ row.location }}</span>
</div>
<div class="rank-card-meta">
<span>{{ row.latestAccount }}</span>
<small>{{ row.latestRole }}</small>
</div>
<div class="rank-card-stats">
<div>
<span>访问</span>
<strong>{{ formatNumber(row.total) }}</strong>
</div>
<div>
<span>异常/拒绝</span>
<strong :class="{ 'denied-highlight': row.riskCount > 0 }">{{ formatNumber(row.riskCount) }}</strong>
</div>
</div>
</article>
</div>
<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 } from "@/api/projectPermissions";
import type {
AccessLogItem,
AccessLogsSummary,
} from "@/types/api";
import { Search as SearchIcon, Document as DocumentIcon, Histogram as HistogramIcon } from "@element-plus/icons-vue";
import { useRoleTemplateMeta } from "@/composables/useRoleTemplateMeta";
import { saveFileWithFeedback } from "@/utils/fileTaskFeedback";
import TimeRangeSelector from "./TimeRangeSelector.vue";
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 = 30_000;
const ACCESS_LOG_EXPORT_PAGE_SIZE = 200;
const IP_RANKING_PAGE_SIZE = 200;
const IP_RANKING_LIMIT = 10;
const TIME_RANGE_PRESETS = [
{ value: "all", label: "全部", hours: null },
{ value: "24h", label: "24小时", hours: 24 },
{ value: "7d", label: "7天", hours: 24 * 7 },
{ value: "30d", label: "30天", hours: 24 * 30 },
] as const;
type TimeRangePreset = (typeof TIME_RANGE_PRESETS)[number]["value"];
type AccessTimeRangePreset = TimeRangePreset | "custom";
interface TrendDrilldownPayload {
preset?: Exclude<TimeRangePreset, "all">;
startAt?: string;
endAt?: string;
allowed?: boolean;
role?: string;
clientType?: string;
keyword?: string;
}
interface IpRankingRow {
ip: string;
location: string;
total: number;
riskCount: number;
highRiskCount: number;
lastSeenAt: string;
latestRequest: string;
latestAccount: string;
latestRole: string;
categoryText: string;
riskLabel: string;
riskTagType: "danger" | "warning" | "info";
}
interface IpRiskDescriptor {
label: string;
tagType: "danger" | "warning" | "info";
priority: number;
}
interface IpRankingSummary {
totalCount: number;
riskCount: number;
highRiskCount: number;
uniqueIpCount: number;
}
type IpRankingAccumulator = IpRankingRow & {
lastSeenAtMs: number;
riskPriority: number;
};
const emptySummary: AccessLogsSummary = {
total_count: 0,
unique_user_count: 0,
unique_ip_count: 0,
denied_count: 0,
avg_elapsed_ms: 0,
};
const emptyIpRankingSummary: IpRankingSummary = {
totalCount: 0,
riskCount: 0,
highRiskCount: 0,
uniqueIpCount: 0,
};
const loading = ref(false);
const rawExportLoading = ref(false);
const accessLogDialogLoading = ref(false);
const ipRankingLoading = ref(false);
const logs = ref<AccessLogItem[]>([]);
const accessDialogLines = ref<string[]>([]);
const ipRankingRows = ref<IpRankingRow[]>([]);
const ipRankingSummary = ref<IpRankingSummary>({ ...emptyIpRankingSummary });
const summary = ref<AccessLogsSummary>(emptySummary);
const total = ref(0);
const page = ref(1);
const pageSize = ref(50);
const timeRangePreset = ref<AccessTimeRangePreset>("24h");
const ipRankingPeriod = ref<TimeRangePreset>("24h");
const trendDrilldownRange = ref<[Date, Date] | null>(null);
const trendDrilldownActive = ref(false);
const accessLogDialogVisible = ref(false);
const ipRankingDialogVisible = ref(false);
const accessTerminalWindowRef = ref<HTMLElement | null>(null);
const realtimeTimer = ref<number | null>(null);
const keywordTimer = ref<number | null>(null);
const detailDrawerVisible = ref(false);
const selectedLog = ref<AccessLogItem | null>(null);
const loadError = ref("");
const lastUpdatedAt = ref<Date | null>(null);
const accessDialogError = ref("");
const ipRankingError = ref("");
const filters = reactive({
role: undefined as string | undefined,
allowed: undefined as boolean | undefined,
clientType: undefined as string | undefined,
keyword: "",
});
const ROLE_FILTER_KEYS = ["ADMIN", "PM", "CRA", "PV", "QA", "CTA"];
const roleFilterOptions = computed(() => roleOptionsFor(ROLE_FILTER_KEYS));
const timeRangePresetOptions = TIME_RANGE_PRESETS;
const ipRankingPeriodOptions = TIME_RANGE_PRESETS;
const selectedIpRankingPeriod = computed(() => TIME_RANGE_PRESETS.find((period) => period.value === ipRankingPeriod.value) || TIME_RANGE_PRESETS[1]);
const clientSourceOptions = [
{ value: "web", label: "网页端" },
{ value: "desktop", label: "桌面端" },
{ value: "unknown", label: "未知/异常" },
];
const SECURITY_CATEGORY_LABELS: Record<string, string> = {
ABNORMAL_IP: "异常IP",
PROBE: "敏感路径探测",
INVALID_TOKEN: "无效令牌",
SERVER_ERROR: "服务异常",
ANONYMOUS_API: "匿名 API",
NOT_FOUND_NOISE: "普通 404",
OTHER: "其他",
};
const SECURITY_SEVERITY_LABELS: Record<string, string> = {
CRITICAL: "严重",
HIGH: "高",
MEDIUM: "中",
LOW: "低",
};
const AUTH_STATUS_LABELS: Record<string, string> = {
ANONYMOUS: "匿名",
INVALID_TOKEN: "无效令牌",
AUTHENTICATED: "已认证",
};
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 isSecurityLog = (row: AccessLogItem) => row.event_type === "security";
const eventTypeLabel = (row: AccessLogItem) => (isSecurityLog(row) ? "安全事件" : "业务访问");
const securityCategoryLabel = (value?: string | null) => SECURITY_CATEGORY_LABELS[value || ""] || value || "其他";
const securitySeverityLabel = (value?: string | null) => SECURITY_SEVERITY_LABELS[value || ""] || value || "-";
const authStatusLabel = (value?: string | null) => AUTH_STATUS_LABELS[value || ""] || value || "-";
const isScriptLikeUserAgent = (userAgent?: string | null) => {
const ua = String(userAgent || "").toLowerCase();
return ["curl", "wget", "python-requests", "go-http-client", "java/", "okhttp", "postman", "insomnia", "httpie"].some((marker) =>
ua.includes(marker),
);
};
const clientSourceLabel = (row: AccessLogItem) => {
const clientType = String(row.client_type || "").toLowerCase();
if (clientType === "web") return "网页端";
if (clientType === "desktop") return "桌面端";
if (isScriptLikeUserAgent(row.user_agent)) return "脚本/自动化";
return "未知/异常";
};
const clientMeta = (row: AccessLogItem) => {
const parts = [row.client_type, row.client_version, row.client_platform].filter(Boolean);
if (parts.length) return parts.join(" / ");
return row.user_agent ? "未标识客户端" : "未记录来源";
};
const buildMeta = (row: AccessLogItem) => [row.build_channel, row.build_commit].filter(Boolean).join(" / ") || "未记录";
const requestSnapshot = (row: AccessLogItem) => row.request_snapshot || {};
const hasCapturedRequestSnapshot = (row: AccessLogItem) => Object.keys(requestSnapshot(row)).length > 0;
const isLegacyPermissionLog = (row: AccessLogItem) => !isSecurityLog(row) && !hasCapturedRequestSnapshot(row) && !row.method && !row.path;
const requestHeaderEntries = (row: AccessLogItem) =>
Object.entries(requestSnapshot(row).headers || row.request_headers || {}).sort(([left], [right]) => left.localeCompare(right));
const requestQueryString = (row: AccessLogItem) => requestSnapshot(row).query_string || "";
const requestMethodValue = (row: AccessLogItem) => requestSnapshot(row).method || row.method || (isSecurityLog(row) ? "HTTP" : "历史未采集");
const requestPathValue = (row: AccessLogItem) => requestSnapshot(row).path || row.path || (isSecurityLog(row) ? row.endpoint_key : `权限点:${row.endpoint_key}`);
const requestTargetValue = (row: AccessLogItem) => {
const path = requestPathValue(row);
const query = requestQueryString(row);
if (!query || path.includes("?")) return path;
return `${path}?${query}`;
};
const requestSummaryValue = (row: AccessLogItem) => {
if (isLegacyPermissionLog(row)) return `历史日志未采集原始请求;权限点 ${row.endpoint_key}`;
return `${requestMethodValue(row)} ${requestTargetValue(row)}`.trim();
};
const rawRequestText = (row: AccessLogItem) => {
if (isLegacyPermissionLog(row)) return `legacy_permission_endpoint=${row.endpoint_key} snapshot=历史日志未采集原始请求`;
return `method=${requestMethodValue(row)} path=${requestTargetValue(row)}`;
};
const accountSecondary = (row: AccessLogItem) => row.user_email || row.user_id || row.account_label || "未知账号";
const endpointTitle = (row: AccessLogItem) => {
if (isSecurityLog(row)) return `${requestMethodValue(row)} ${requestTargetValue(row)}`.trim();
return row.endpoint_key;
};
const endpointMeta = (row: AccessLogItem) => {
if (isSecurityLog(row)) {
const parts = [securityCategoryLabel(row.category || row.role), row.status_code ? `HTTP ${row.status_code}` : "", securitySeverityLabel(row.severity)].filter(Boolean);
return parts.join(" / ");
}
return roleLabel(row.role);
};
const requestMain = (row: AccessLogItem) => {
if (isSecurityLog(row)) {
const method = row.method || "HTTP";
const status = row.status_code ? `${row.status_code}` : "-";
return `${method} ${status} / ${securityCategoryLabel(row.category || row.role)}`;
}
return endpointTitle(row);
};
const requestSub = (row: AccessLogItem) => {
if (isSecurityLog(row)) return requestTargetValue(row);
return `${roleLabel(row.role)} / ${clientSourceLabel(row)} / ${clientMeta(row)}`;
};
const accessResultLabel = (row: AccessLogItem) => {
if (isSecurityLog(row)) return row.status_code ? `HTTP ${row.status_code}` : "安全事件";
return row.allowed ? "允许" : "拒绝";
};
const accessResultTagType = (row: AccessLogItem) => {
if (isSecurityLog(row)) return row.status_code && row.status_code >= 500 ? "danger" : "warning";
return row.allowed ? "success" : "danger";
};
const requestOverviewFieldItems = (row: AccessLogItem) => {
const fields = [
{ label: "时间", value: formatTerminalTime(row.created_at) },
{ label: isSecurityLog(row) ? "接口类型" : "权限点", value: endpointTitle(row) },
{ label: "角色/分类", value: endpointMeta(row) },
];
if (!isSecurityLog(row)) return fields;
return [
...fields,
{ label: "认证状态", value: authStatusLabel(row.auth_status) },
{ label: "风险等级", value: securitySeverityLabel(row.severity) },
];
};
const auditMetadataFieldItems = (row: AccessLogItem) => [
{ label: "日志ID", value: row.id },
{ label: "类型", value: eventTypeLabel(row) },
{ label: "项目ID", value: row.study_id || "无项目" },
{ label: "用户ID", value: row.user_id || "未知" },
];
const requestSnapshotFieldItems = (row: AccessLogItem) => {
if (!hasCapturedRequestSnapshot(row)) {
return [
{ label: "采集状态", value: "历史日志未采集原始请求快照" },
{ label: "可用请求信息", value: isSecurityLog(row) ? `${requestMethodValue(row)} ${requestTargetValue(row)}`.trim() : row.endpoint_key },
{ label: "说明", value: "该记录创建于请求快照能力上线前,无法还原 Method/Path/Query/Headers 原文" },
{ label: "Body", value: "未采集明文" },
];
}
const snapshot = requestSnapshot(row);
const client = snapshot.client || {};
const content = snapshot.content || {};
const body = snapshot.body || {};
return [
{ label: "HTTP Version", value: snapshot.http_version || "未记录" },
{ label: "Scheme", value: snapshot.scheme || "未记录" },
{ label: "Client Socket", value: [client.host, client.port].filter(Boolean).join(":") || "未记录" },
{ label: "Content-Type", value: content.type || "未记录" },
{ label: "Content-Length", value: content.length || "未记录" },
{ label: "Body", value: body.captured ? "已采集" : "未采集明文" },
{ label: "Body Policy", value: body.reason || "body_not_captured_by_audit_policy" },
];
};
const rawRequestBodyLine = (row: AccessLogItem) => {
const body = requestSnapshot(row).body || {};
if (body.captured) return "[request body captured]";
return `[request body not captured: ${body.reason || "body_not_captured_by_audit_policy"}]`;
};
const buildRawRequestBlock = (row: AccessLogItem) => {
if (isLegacyPermissionLog(row)) {
return [
`# 历史日志未采集原始 HTTP 请求`,
`# permission_endpoint: ${row.endpoint_key}`,
`# created_at: ${formatTerminalTime(row.created_at)}`,
`# audit_line: ${buildTerminalLine(row)}`,
].join("\n");
}
const version = requestSnapshot(row).http_version ? `HTTP/${requestSnapshot(row).http_version}` : "HTTP";
const lines = [`${requestMethodValue(row)} ${requestTargetValue(row)} ${version}`.trim()];
const headers = requestHeaderEntries(row);
if (headers.length) {
headers.forEach(([name, value]) => {
lines.push(`${name}: ${value}`);
});
} else if (row.user_agent) {
lines.push(`user-agent: ${row.user_agent}`);
}
lines.push("");
lines.push(rawRequestBodyLine(row));
return lines.join("\n");
};
const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
const formatLastUpdated = (value: Date) => {
return `${value.getFullYear()}/${value.getMonth() + 1}/${value.getDate()} ${String(value.getHours()).padStart(2, "0")}:${String(value.getMinutes()).padStart(2, "0")}:${String(value.getSeconds()).padStart(2, "0")}`;
};
const buildTimeRangeFromPreset = (preset: AccessTimeRangePreset): [Date, Date] | null => {
if (preset === "custom") return null;
const option = TIME_RANGE_PRESETS.find((item) => item.value === preset) || TIME_RANGE_PRESETS[1];
if (!option.hours) return null;
const end = new Date();
const start = new Date(end.getTime() - option.hours * 60 * 60 * 1000);
return [start, end];
};
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) => {
if (isSecurityLog(row)) {
const ip = row.ip_address || "-";
const location = formatIpLocation(row) || "未知属地";
const userAgent = row.user_agent || "-";
return `${formatTerminalTime(row.created_at)} [SECURITY:${securitySeverityLabel(row.severity)}] status=${row.status_code || "-"} category=${securityCategoryLabel(row.category)} auth=${authStatusLabel(row.auth_status)} account=${row.account_label || row.user_name || "未知账号"} source=${clientSourceLabel(row)} client=${clientMeta(row)} ip=${ip} loc=${location} ${rawRequestText(row)} ua=${userAgent} cost=${row.elapsed_ms.toFixed(1)}ms`;
}
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}] source=${clientSourceLabel(row)} client=${clientMeta(row)} role=${roleLabel(row.role)} user=${user} account=${row.user_email || row.user_id} ip=${ip} loc=${location} endpoint=${row.endpoint_key} ${rawRequestText(row)} cost=${row.elapsed_ms.toFixed(1)}ms`;
};
const onFilterChange = () => {
page.value = 1;
loadData();
};
const onTimePresetChange = () => {
trendDrilldownRange.value = null;
onFilterChange();
};
const onKeywordInput = () => {
if (keywordTimer.value) window.clearTimeout(keywordTimer.value);
keywordTimer.value = window.setTimeout(() => {
keywordTimer.value = null;
onFilterChange();
}, 350);
};
const resetFilters = () => {
timeRangePreset.value = "24h";
trendDrilldownRange.value = null;
trendDrilldownActive.value = false;
filters.role = undefined;
filters.allowed = undefined;
filters.clientType = undefined;
filters.keyword = "";
onFilterChange();
};
const onPageSizeChange = () => {
page.value = 1;
loadData();
};
const buildQueryParams = (overrides: Record<string, any> = {}) => {
const params: Record<string, any> = {
page: page.value,
page_size: pageSize.value,
};
if (filters.role) params.role = filters.role;
if (filters.allowed !== undefined) params.allowed = filters.allowed;
if (filters.clientType) params.client_type = filters.clientType;
if (trendDrilldownActive.value) params.event_type = "permission";
const keywordToken = filters.keyword.trim();
if (keywordToken) {
params.keyword = keywordToken;
if (/^[0-9a-f:.]+$/i.test(keywordToken)) params.client_ip = keywordToken;
}
const presetRange = trendDrilldownRange.value || buildTimeRangeFromPreset(timeRangePreset.value);
if (presetRange) {
params.start_time = presetRange[0].toISOString();
params.end_time = presetRange[1].toISOString();
}
return { ...params, ...overrides };
};
const scrollAccessTerminalToBottom = () => {
nextTick(() => {
const terminal = accessTerminalWindowRef.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;
loadError.value = "";
lastUpdatedAt.value = new Date();
} catch {
loadError.value = "访问日志加载失败,表格中可能是上次成功获取的数据";
} finally {
loading.value = false;
}
};
const stopRealtimePolling = () => {
if (!realtimeTimer.value) return;
window.clearInterval(realtimeTimer.value);
realtimeTimer.value = null;
};
const stopTimers = () => {
stopRealtimePolling();
if (keywordTimer.value) {
window.clearTimeout(keywordTimer.value);
keywordTimer.value = null;
}
};
const startRealtimePolling = () => {
stopRealtimePolling();
if (document.visibilityState !== "visible") return;
realtimeTimer.value = window.setInterval(() => {
loadData({ silent: true });
}, REALTIME_POLL_INTERVAL_MS);
};
const onVisibilityChange = () => {
if (document.visibilityState === "visible") {
loadData({ silent: true });
startRealtimePolling();
} else {
stopRealtimePolling();
}
};
const refresh = () => {
loadData();
};
const drilldownRangeLabel = computed(() => {
if (!trendDrilldownRange.value) return "";
const [start, end] = trendDrilldownRange.value;
const compact = (value: Date) => `${value.getMonth() + 1}/${value.getDate()} ${String(value.getHours()).padStart(2, "0")}:${String(value.getMinutes()).padStart(2, "0")}`;
return `${compact(start)}${compact(end)}`;
});
const clearTrendDrilldownRange = () => {
trendDrilldownRange.value = null;
trendDrilldownActive.value = false;
timeRangePreset.value = "24h";
onFilterChange();
};
const applyTrendFilter = async (payload: TrendDrilldownPayload) => {
page.value = 1;
trendDrilldownActive.value = true;
filters.allowed = payload.allowed;
filters.role = payload.role;
filters.clientType = payload.clientType;
filters.keyword = payload.keyword || "";
if (payload.startAt && payload.endAt) {
trendDrilldownRange.value = [new Date(payload.startAt), new Date(payload.endAt)];
timeRangePreset.value = "custom";
} else {
trendDrilldownRange.value = null;
timeRangePreset.value = payload.preset || "24h";
}
await loadData();
};
const openLogDetail = (row: AccessLogItem) => {
selectedLog.value = row;
detailDrawerVisible.value = true;
};
const openAccessLogDialog = async () => {
accessLogDialogVisible.value = true;
accessLogDialogLoading.value = true;
accessDialogLines.value = [];
accessDialogError.value = "";
try {
accessDialogLines.value = await fetchAllRawAccessLogLines();
scrollAccessTerminalToBottom();
} catch {
accessDialogError.value = "访问日志加载失败,请稍后重试";
} finally {
accessLogDialogLoading.value = false;
}
};
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 fetchAllRawAccessLogLines = async () => {
const first = await fetchAccessLogs(buildQueryParams({ page: 1, page_size: ACCESS_LOG_EXPORT_PAGE_SIZE }));
const items = [...first.data.items];
const totalPages = Math.ceil(first.data.total / ACCESS_LOG_EXPORT_PAGE_SIZE);
for (let nextPage = 2; nextPage <= totalPages; nextPage += 1) {
const res = await fetchAccessLogs(buildQueryParams({ page: nextPage, page_size: ACCESS_LOG_EXPORT_PAGE_SIZE }));
items.push(...res.data.items);
}
return items
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
.map(buildTerminalLine);
};
const ipRankingTimeRange = () => {
const range = buildTimeRangeFromPreset(selectedIpRankingPeriod.value.value);
if (!range) return {};
const [start, end] = range;
return {
start_time: start.toISOString(),
end_time: end.toISOString(),
};
};
const isRiskAccessRow = (row: AccessLogItem) => isSecurityLog(row) || row.allowed === false;
const isHighRiskAccessRow = (row: AccessLogItem) => {
const severity = String(row.severity || "").toUpperCase();
return severity === "HIGH" || severity === "CRITICAL" || Boolean(isSecurityLog(row) && row.status_code && row.status_code >= 500);
};
const accessRowCategoryText = (row: AccessLogItem) => {
if (isSecurityLog(row)) return `${securityCategoryLabel(row.category || row.role)} / ${securitySeverityLabel(row.severity)}`;
return row.allowed ? "业务访问" : "权限拒绝";
};
const riskDescriptorForRow = (row: AccessLogItem): IpRiskDescriptor => {
if (!isRiskAccessRow(row)) return { label: "", tagType: "info", priority: 0 };
if (!isSecurityLog(row)) return { label: "权限拒绝", tagType: "warning", priority: 30 };
const category = String(row.category || row.role || "").toUpperCase();
if (category === "ABNORMAL_IP") return { label: "异常IP", tagType: "danger", priority: 90 };
if (category === "PROBE") return { label: "敏感探测", tagType: "danger", priority: 80 };
if (category === "SERVER_ERROR") return { label: "服务异常", tagType: "danger", priority: 75 };
if (category === "INVALID_TOKEN") return { label: "无效令牌", tagType: "warning", priority: 70 };
if (category === "ANONYMOUS_API") return { label: "匿名访问", tagType: "warning", priority: 60 };
if (category === "NOT_FOUND_NOISE") return { label: "普通404", tagType: "info", priority: 20 };
if (row.status_code && row.status_code >= 500) return { label: "服务异常", tagType: "danger", priority: 75 };
return { label: "安全事件", tagType: "warning", priority: 50 };
};
const accessRowRoleText = (row: AccessLogItem) => {
if (isSecurityLog(row)) return securityCategoryLabel(row.category || row.role);
return roleLabel(row.role);
};
const accessRowRequestText = (row: AccessLogItem) => (isSecurityLog(row) ? requestSummaryValue(row) : endpointTitle(row));
const accountDisplayText = (row: AccessLogItem) => {
const primary = row.user_name || row.account_label || "未知账号";
const secondary = accountSecondary(row);
if (!secondary || secondary === primary) return primary;
return `${primary} / ${secondary}`;
};
const riskRankCardClass = (row: IpRankingRow) => {
if (row.riskCount <= 0) return [];
return ["risk-rank-card", `risk-rank-card--${row.riskTagType}`];
};
const fetchAccessRowsForIpRanking = async () => {
const range = ipRankingTimeRange();
const first = await fetchAccessLogs({ ...range, page: 1, page_size: IP_RANKING_PAGE_SIZE });
const items = [...first.data.items];
const totalPages = Math.ceil(first.data.total / IP_RANKING_PAGE_SIZE);
for (let nextPage = 2; nextPage <= totalPages; nextPage += 1) {
const res = await fetchAccessLogs({ ...range, page: nextPage, page_size: IP_RANKING_PAGE_SIZE });
items.push(...res.data.items);
}
return items;
};
const buildIpRankingRows = (items: AccessLogItem[]) => {
const byIp = new Map<string, IpRankingAccumulator>();
const summaryCounts: IpRankingSummary = {
totalCount: items.length,
riskCount: 0,
highRiskCount: 0,
uniqueIpCount: 0,
};
items.forEach((item) => {
const ip = item.ip_address || "未知 IP";
const seenAtMs = Number.isFinite(new Date(item.created_at).getTime()) ? new Date(item.created_at).getTime() : 0;
const isRisk = isRiskAccessRow(item);
const isHighRisk = isHighRiskAccessRow(item);
const riskDescriptor = riskDescriptorForRow(item);
if (isRisk) summaryCounts.riskCount += 1;
if (isHighRisk) summaryCounts.highRiskCount += 1;
const existing = byIp.get(ip) || {
ip,
location: formatIpLocation(item) || "未知位置",
total: 0,
riskCount: 0,
highRiskCount: 0,
lastSeenAt: item.created_at,
lastSeenAtMs: 0,
latestRequest: accessRowRequestText(item),
latestAccount: accountDisplayText(item),
latestRole: accessRowRoleText(item),
categoryText: accessRowCategoryText(item),
riskLabel: "",
riskTagType: "info",
riskPriority: 0,
};
existing.total += 1;
if (isRisk) existing.riskCount += 1;
if (isHighRisk) existing.highRiskCount += 1;
if (riskDescriptor.priority > existing.riskPriority) {
existing.riskLabel = riskDescriptor.label;
existing.riskTagType = riskDescriptor.tagType;
existing.riskPriority = riskDescriptor.priority;
}
if (seenAtMs >= existing.lastSeenAtMs) {
existing.location = formatIpLocation(item) || existing.location;
existing.lastSeenAt = item.created_at;
existing.lastSeenAtMs = seenAtMs;
existing.latestRequest = accessRowRequestText(item);
existing.latestAccount = accountDisplayText(item);
existing.latestRole = accessRowRoleText(item);
existing.categoryText = accessRowCategoryText(item);
}
byIp.set(ip, existing);
});
summaryCounts.uniqueIpCount = byIp.size;
const rows = Array.from(byIp.values())
.sort((left, right) => {
if (right.riskCount !== left.riskCount) return right.riskCount - left.riskCount;
if (right.highRiskCount !== left.highRiskCount) return right.highRiskCount - left.highRiskCount;
if (right.total !== left.total) return right.total - left.total;
return right.lastSeenAtMs - left.lastSeenAtMs;
})
.slice(0, IP_RANKING_LIMIT)
.map(({ lastSeenAtMs: _lastSeenAtMs, riskPriority: _riskPriority, ...row }) => row);
return { rows, summary: summaryCounts };
};
const loadIpRankingData = async () => {
ipRankingLoading.value = true;
ipRankingError.value = "";
try {
const ranking = buildIpRankingRows(await fetchAccessRowsForIpRanking());
ipRankingRows.value = ranking.rows;
ipRankingSummary.value = ranking.summary;
} catch {
ipRankingError.value = "IP 访问排行加载失败,当前内容可能是上次成功结果";
} finally {
ipRankingLoading.value = false;
}
};
const selectIpRankingPeriod = async (period: any) => {
ipRankingPeriod.value = period as TimeRangePreset;
await loadIpRankingData();
};
const openIpRankingDialog = async () => {
ipRankingDialogVisible.value = true;
await loadIpRankingData();
};
const downloadInterfaceLog = async () => {
rawExportLoading.value = true;
try {
await downloadLogFile("raw-access-logs.log", await fetchAllRawAccessLogLines());
} finally {
rawExportLoading.value = false;
}
};
onMounted(async () => {
await loadRoleTemplates();
refresh();
document.addEventListener("visibilitychange", onVisibilityChange);
startRealtimePolling();
});
onBeforeUnmount(() => {
document.removeEventListener("visibilitychange", onVisibilityChange);
stopTimers();
});
defineExpose({ refresh, applyTrendFilter });
</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;
--filter-control-height: 26px;
--filter-font-size: 12px;
--card-radius: 12px;
--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: 8px;
}
.access-overview {
padding: 8px 12px 6px;
border: 1px solid var(--audit-border);
border-radius: var(--card-radius);
background: #fff;
box-shadow: var(--card-shadow);
}
.access-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 32px;
padding: 0 0 7px;
border-bottom: 1px solid #edf1f6;
}
.access-toolbar-title h3 {
margin: 0;
color: var(--audit-ink);
font-size: 15px;
font-weight: 650;
}
.access-toolbar-title p {
margin: 3px 0 0;
color: var(--audit-muted);
font-size: 12px;
}
.access-toolbar-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.audit-filters {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
padding: 10px 12px;
background: transparent;
border: none;
box-shadow: none;
border-bottom: 1px solid var(--audit-border);
margin-bottom: 0;
}
.filter-inputs {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: nowrap;
}
.load-alert {
flex: 0 0 auto;
}
.filter-time-presets {
flex-shrink: 0;
}
.filter-time-presets :deep(.el-radio-button__inner) {
height: var(--filter-control-height);
padding: 0 12px;
border-radius: 0;
font-size: var(--filter-font-size);
font-weight: 500;
line-height: calc(var(--filter-control-height) - 2px);
}
.filter-time-presets :deep(.el-radio-button:first-child .el-radio-button__inner) {
border-radius: 7px 0 0 7px;
}
.filter-time-presets :deep(.el-radio-button:last-child .el-radio-button__inner) {
border-radius: 0 7px 7px 0;
}
.filter-time-presets :deep(.el-radio-button.is-active .el-radio-button__inner) {
font-weight: 600;
}
.filter-role {
width: 110px;
}
.filter-result {
width: 88px;
}
.filter-source {
width: 100px;
}
.filter-keyword {
flex: 1 1 280px;
min-width: 240px;
max-width: 360px;
}
.audit-filters :deep(.el-select__wrapper),
.audit-filters :deep(.el-input__wrapper) {
min-height: var(--filter-control-height);
padding-top: 0;
padding-bottom: 0;
border-radius: 7px;
font-size: var(--filter-font-size);
}
.audit-filters :deep(.el-select__placeholder),
.audit-filters :deep(.el-select__selected-item),
.audit-filters :deep(.el-input__inner) {
font-size: var(--filter-font-size);
line-height: 1.4;
}
.filter-reset {
flex-shrink: 0;
height: var(--filter-control-height);
padding: 0 11px;
border-radius: 7px;
font-size: var(--filter-font-size);
font-weight: 500;
}
.audit-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0;
padding-top: 5px;
}
.metric-card {
display: flex;
align-items: center;
gap: 9px;
min-height: 44px;
padding: 5px 12px;
border-left: 1px solid #edf1f6;
}
.metric-card:first-child {
border-left: 0;
}
.metric-card::before {
display: none;
}
.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: 28px;
height: 28px;
border-radius: 8px;
flex-shrink: 0;
}
.metric-icon svg {
width: 14px;
height: 14px;
}
.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: 1px;
min-width: 0;
}
.metric-label {
color: var(--audit-muted);
font-size: 11px;
font-weight: 500;
}
.metric-value {
color: var(--audit-ink);
font-size: 18px;
font-weight: 700;
line-height: 1.1;
}
.access-log-card {
min-width: 0;
padding: 0;
overflow: hidden;
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: 10px;
margin-bottom: 10px;
}
.section-title-group h4 {
margin: 0;
color: var(--audit-ink);
font-size: 15px;
font-weight: 600;
}
.section-title-group h5 {
margin: 0;
color: var(--audit-ink);
font-size: 13px;
font-weight: 600;
}
.section-title-group p {
margin: 4px 0 0;
color: var(--audit-muted);
font-size: 12px;
}
.table-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
}
.log-surface {
display: flex;
flex-direction: column;
gap: 0;
}
.table-shell {
min-width: 0;
overflow: hidden;
border: 0;
border-radius: 0;
}
.access-table {
--el-table-header-bg-color: #f8fafc;
font-size: 12px;
}
.access-table :deep(.el-table__cell) {
padding: 5px 0;
}
.access-table :deep(.el-table__header .cell) {
color: #64748b;
font-size: 11.5px;
font-weight: 600;
}
.access-table :deep(.cell) {
padding: 0 8px;
}
.access-table :deep(.el-scrollbar__wrap) {
overflow-x: hidden;
}
.access-table :deep(.el-scrollbar__bar.is-horizontal) {
display: none;
}
.time-cell,
.ip-cell,
.location-cell,
.elapsed-cell strong {
color: #475569;
font-variant-numeric: tabular-nums;
}
.location-cell,
.ip-cell {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-cell,
.request-cell,
.elapsed-cell {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.account-cell strong,
.request-cell strong {
overflow: hidden;
color: var(--audit-ink);
font-size: 12.5px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.request-cell strong {
display: flex;
align-items: center;
gap: 6px;
}
.request-cell strong span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-cell span,
.request-cell > span {
overflow: hidden;
color: var(--audit-muted);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.elapsed-cell {
display: flex;
align-items: flex-start;
flex-direction: column;
gap: 3px;
}
.elapsed-cell strong {
font-size: 11px;
}
.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;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.ip-ranking-dialog {
display: flex;
flex-direction: column;
gap: 12px;
}
.ip-ranking-list-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
color: var(--audit-muted);
font-size: 12px;
}
.ip-rank-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
}
.ip-rank-card {
display: flex;
min-width: 0;
flex-direction: column;
gap: 8px;
padding: 10px;
border: 1px solid #edf2f7;
border-radius: 8px;
background: #fff;
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
}
.ip-rank-card:hover {
border-color: #dbe5ef;
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.06);
transform: translateY(-1px);
}
.risk-rank-card {
border-color: #dbe5ef;
}
.risk-rank-card--danger {
border-color: #fecaca;
background: #fff7f7;
}
.risk-rank-card--warning {
border-color: #fed7aa;
background: #fffbeb;
}
.risk-rank-card--info {
border-color: #dbeafe;
background: #f8fafc;
}
.rank-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
}
.rank-no {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
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-risk-tag {
flex-shrink: 0;
}
.rank-card-ip,
.rank-card-meta {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.rank-card-ip strong {
overflow: hidden;
color: var(--audit-ink);
font-size: 13.5px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.rank-card-ip span,
.rank-card-meta span,
.rank-card-meta small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rank-card-ip span {
color: #94a3b8;
font-size: 10.5px;
}
.rank-card-meta span {
color: #475569;
font-size: 11px;
}
.rank-card-meta small {
color: var(--audit-muted);
font-size: 10.5px;
}
.rank-card-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.rank-card-stats div {
min-width: 0;
padding: 6px 7px;
border-radius: 7px;
background: #f8fafc;
}
.rank-card-stats span {
display: block;
color: var(--audit-muted);
font-size: 11px;
}
.rank-card-stats strong {
display: block;
margin-top: 2px;
color: var(--audit-ink);
font-size: 15px;
line-height: 1.1;
}
.rank-card-stats .denied-highlight {
color: var(--audit-danger);
}
.terminal-window {
min-height: 360px;
max-height: 520px;
overflow: auto;
padding: 10px;
border: 1px solid #1e293b;
border-radius: 10px;
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: 11.5px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
}
.logs-pagination {
display: flex;
justify-content: flex-end;
padding: 8px 12px;
border-top: 1px solid var(--audit-border);
background: #fff;
}
.detail-stack {
display: flex;
flex-direction: column;
gap: 14px;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.detail-card,
.detail-section {
min-width: 0;
padding: 14px;
border: 1px solid var(--audit-border);
border-radius: 12px;
background: #fff;
}
.detail-card {
display: flex;
flex-direction: column;
gap: 4px;
}
.detail-card span,
.detail-section h4 {
margin: 0;
color: var(--audit-muted);
font-size: 12px;
font-weight: 600;
}
.detail-card strong {
color: var(--audit-ink);
font-size: 15px;
}
.detail-card small,
.detail-section p {
margin: 0;
color: #475569;
font-size: 12px;
line-height: 1.6;
word-break: break-word;
}
.detail-section h4 {
margin-bottom: 8px;
}
.detail-request-line {
margin-bottom: 10px !important;
padding: 9px 10px;
border-radius: 8px;
background: #f8fafc;
color: var(--audit-ink) !important;
font-family: "JetBrains Mono", "SFMono-Regular", "Cascadia Code", "Menlo", monospace;
font-size: 12px !important;
font-weight: 600;
}
.detail-field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.detail-field-item {
min-width: 0;
padding: 8px 10px;
border-radius: 8px;
background: #f8fafc;
}
.detail-field-item span {
display: block;
margin-bottom: 3px;
color: var(--audit-muted);
font-size: 11px;
font-weight: 600;
}
.detail-field-item strong {
display: block;
overflow-wrap: anywhere;
color: var(--audit-ink);
font-size: 12px;
line-height: 1.5;
}
.detail-raw-line {
margin: 0;
overflow: auto;
padding: 10px;
border-radius: 8px;
background: #0f172a;
color: #e2e8f0;
font-family: "JetBrains Mono", "SFMono-Regular", "Cascadia Code", "Menlo", monospace;
font-size: 11.5px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
}
.detail-more {
border-top: 1px solid var(--audit-border);
border-bottom: 1px solid var(--audit-border);
}
.detail-more :deep(.el-collapse-item__header) {
height: 42px;
color: #475569;
font-size: 12px;
font-weight: 600;
}
.detail-more :deep(.el-collapse-item__content) {
padding-bottom: 4px;
}
.detail-subsection {
padding: 10px 0 14px;
}
.detail-subsection + .detail-subsection {
border-top: 1px solid #edf2f7;
}
.detail-subsection h5 {
margin: 0 0 8px;
color: var(--audit-muted);
font-size: 11px;
font-weight: 600;
}
@media (max-width: 1100px) {
.audit-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.metric-card:nth-child(odd) {
border-left: 0;
}
.metric-card:nth-child(n + 3) {
border-top: 1px solid #edf1f6;
}
.ip-rank-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (max-width: 920px) {
.audit-filters {
flex-wrap: wrap;
}
.filter-keyword {
max-width: none;
}
.ip-rank-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.access-toolbar,
.access-toolbar-actions {
align-items: flex-start;
flex-direction: column;
}
.access-toolbar-actions {
width: 100%;
}
.audit-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ip-rank-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.table-actions {
justify-content: flex-start;
}
}
@media (max-width: 520px) {
.audit-metrics {
grid-template-columns: 1fr;
}
.metric-card {
border-top: 1px solid #edf1f6;
border-left: 0;
}
.metric-card:first-child {
border-top: 0;
}
.ip-rank-grid {
grid-template-columns: 1fr;
}
}
</style>