ab59476d10
- 优化桌面标签、上下文标题、前进后退、导航栏隐藏和原生菜单体验 - 补充登录会话 IP 采集、地理位置回退、管理端展示及数据库迁移 - 更新桌面发布检查、运维文档和前后端测试覆盖
1894 lines
56 KiB
Vue
1894 lines
56 KiB
Vue
<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>
|