Files
ctms/frontend/src/components/SecurityCenter.vue
T
Cheng Zhou 6b55c18610
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
refactor(frontend): 按需加载页面并清理未使用代码
- 将业务页面路由统一改为动态导入,拆分首屏入口与各功能模块构建产物。

- 将网页端和桌面端布局改为异步组件,避免两套平台布局同时进入初始包。

- 新增 Element Plus 按需安装入口,并通过全局配置组件统一注入中文语言包。

- 提取 API 运行时钩子,在应用启动时注入项目清理、令牌续期和认证失效退出能力。

- 将权限监控面板及地图资源改为延迟加载,补充地图加载状态、失败提示和切换竞态保护。

- 删除已被现有工作流替代的项目成员、接口权限、中心绑定、培训表单及旧项目首页等页面。

- 清理废弃的快捷操作、项目选择、用户选择、FAQ 表单、风险占位组件和旧地图辅助模块。

- 移除未使用的 API 方法、类型、字典、状态机、展示工具、样式和项目详情编辑逻辑。

- 开启 TypeScript 未使用变量与参数检查,并同步收紧相关测试和组件暴露类型。

- 移除未使用的 updater、date-fns 和 Sass 前端依赖,更新锁文件并删除旧 CSS 清洗插件。

- 更新路由、Axios、ETMF、通知、权限监控和桌面布局测试以覆盖重构后的边界。
2026-07-16 14:58:47 +08:00

1881 lines
52 KiB
Vue

<template>
<div class="security-center">
<el-alert v-if="loadError" :title="loadError" type="warning" show-icon :closable="false" />
<section class="security-overview">
<section class="security-toolbar">
<div class="security-toolbar-title">
<h3>安全中心</h3>
</div>
<div class="security-toolbar-actions">
<TimeRangeSelector v-model="timeRangePreset" @change="onTimeRangeChange" />
<span v-if="lastUpdatedAt" class="ctms-updated-at">更新于 {{ formatLastUpdated(lastUpdatedAt) }}</span>
<el-button size="small" type="primary" :loading="loading" @click="loadSecurityEvents">刷新</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>
<section class="security-analysis-card">
<div class="section-head analysis-summary-head">
<div class="section-title-group">
<h4>异常分析</h4>
<p>高风险来源与风险等级摘要</p>
</div>
<div class="analysis-summary-actions">
<el-tag effect="plain" size="small" type="danger">高危 {{ formatNumber(securitySummary.high_risk_count) }}</el-tag>
<el-button class="ctms-btn-dialog ctms-btn-capsule-danger" @click="openAnalysisDialog">
查看异常分析
<el-icon class="el-icon--right"><HistogramIcon /></el-icon>
</el-button>
</div>
</div>
<div class="analysis-summary-body">
<div class="risk-preview-list">
<div v-for="(item, index) in topRiskPreview" :key="item.ip" class="risk-preview-row">
<span class="rank-no" :class="{ 'rank-top': index < 3 }">{{ String(index + 1).padStart(2, "0") }}</span>
<div class="risk-preview-main">
<strong>{{ item.ip }}</strong>
<span>{{ item.location }} · {{ item.categoryText }}</span>
</div>
<div class="risk-preview-count">
<strong>{{ item.total }}</strong>
<small :class="{ 'denied-highlight': item.highRiskCount > 0 }">{{ item.highRiskCount }} 高危</small>
</div>
</div>
<el-empty v-if="!topRiskPreview.length" description="暂无异常数据" :image-size="48" />
</div>
<div class="risk-summary-panel">
<strong class="risk-summary-title">风险等级</strong>
<div class="risk-level-list">
<div v-for="item in riskLevelStats" :key="item.value" class="risk-level-row">
<span class="risk-dot" :class="item.tone" />
<span>{{ item.label }}</span>
<div class="risk-level-meter">
<span :class="item.tone" :style="{ width: `${item.percent}%` }" />
</div>
<strong>{{ item.total }}</strong>
</div>
</div>
</div>
</div>
</section>
<section ref="securityEventsSectionRef" class="security-table-wrap">
<div class="section-head table-head">
<section class="audit-filters detail-filters">
<el-select v-model="severityFilter" placeholder="风险等级" clearable size="small" style="width: 112px" @change="onFilterChange">
<el-option v-for="item in severityOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-select v-model="categoryFilter" placeholder="事件分类" clearable size="small" style="width: 132px" @change="onFilterChange">
<el-option v-for="item in categoryOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-select v-model="authFilter" placeholder="认证状态" clearable size="small" style="width: 122px" @change="onFilterChange">
<el-option label="匿名" value="ANONYMOUS" />
<el-option label="无效令牌" value="INVALID_TOKEN" />
<el-option label="已认证" value="AUTHENTICATED" />
</el-select>
<el-input
v-model="keyword"
placeholder="搜索 IP、位置、账号、路径或 UA"
clearable
size="small"
class="security-search"
:prefix-icon="SearchIcon"
/>
<el-button size="small" class="ctms-btn-dialog ctms-btn-gradient-indigo" :loading="securityLogDialogLoading" @click="openSecurityLogDialog">
安全日志
<el-icon class="el-icon--right"><DocumentIcon /></el-icon>
</el-button>
</section>
<el-tag effect="plain" size="small" type="info">{{ securityTotal }} 条</el-tag>
</div>
<el-table :data="paginatedEvents" v-loading="loading" class="security-table" size="small" table-layout="fixed">
<el-table-column label="级别" width="80">
<template #default="{ row }">
<el-tag :type="severityTagType(row.severity)" effect="dark" size="small">{{ severityLabel(row.severity) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="分类" width="110">
<template #default="{ row }">
<span class="category-pill">{{ categoryLabel(row.category) }}</span>
</template>
</el-table-column>
<el-table-column label="时间" width="160">
<template #default="{ row }">
<span class="time-cell">{{ formatTime(row.created_at) }}</span>
</template>
</el-table-column>
<el-table-column label="IP" width="140">
<template #default="{ row }">
<span class="ip-cell">{{ row.client_ip || "未知 IP" }}</span>
</template>
</el-table-column>
<el-table-column label="位置" width="160">
<template #default="{ row }">
<span class="location-cell">{{ formatIpLocation(row) }}</span>
</template>
</el-table-column>
<el-table-column label="账号" width="130" prop="account_label" />
<el-table-column label="请求" min-width="260">
<template #default="{ row }">
<div class="request-cell">
<strong>{{ row.method }} {{ row.status_code }} / {{ resolveEndpointType(row.path) }}</strong>
<span>{{ securityRequestTarget(row) }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="耗时" width="90">
<template #default="{ row }">
<span class="elapsed-cell-strong">{{ row.elapsed_ms.toFixed(1) }}ms</span>
</template>
</el-table-column>
<el-table-column label="操作" width="72" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="securityTotal > 0" class="pagination-wrap">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]"
:total="securityTotal"
layout="prev, pager, next, sizes, total"
small
@size-change="onPageSizeChange"
@current-change="loadSecurityEvents"
/>
</div>
</section>
<el-drawer v-model="detailVisible" size="520px" direction="rtl" :show-close="false">
<template #header>
<div class="security-detail-head">
<strong>安全事件详情</strong>
<el-button size="small" @click="detailVisible = false">关闭</el-button>
</div>
</template>
<div v-if="selectedEvent" class="security-detail detail-stack">
<div class="detail-grid">
<div class="detail-card">
<span>账号</span>
<strong>{{ selectedEvent.account_label || "未知账号" }}</strong>
<small>{{ selectedEvent.user_identifier || authLabel(selectedEvent.auth_status) }}</small>
</div>
<div class="detail-card">
<span>来源</span>
<strong>{{ securityClientSourceLabel(selectedEvent) }}</strong>
<small>{{ securityClientMeta(selectedEvent) }}</small>
</div>
<div class="detail-card">
<span>IP / 属地</span>
<strong>{{ selectedEvent.client_ip || "未知 IP" }}</strong>
<small>{{ formatIpLocation(selectedEvent) }}</small>
</div>
<div class="detail-card">
<span>结果 / 耗时</span>
<strong>HTTP {{ selectedEvent.status_code }}</strong>
<small>{{ selectedEvent.elapsed_ms.toFixed(1) }}ms</small>
</div>
</div>
<div class="detail-section detail-section--request">
<h4>请求概览</h4>
<p class="detail-request-line">{{ securityRequestMethod(selectedEvent) }} {{ securityRequestTarget(selectedEvent) }}</p>
<div class="detail-field-grid">
<div v-for="field in securityRequestOverviewFields(selectedEvent)" :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">{{ buildSecurityRawRequestBlock(selectedEvent) }}</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 securityAuditMetadataFields(selectedEvent)" :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 securityRequestSnapshotFields(selectedEvent)" :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>{{ selectedEvent.user_agent || "未记录" }}</strong>
</div>
<div class="detail-field-item">
<span>构建信息</span>
<strong>{{ securityBuildMeta(selectedEvent) }}</strong>
</div>
</div>
</div>
</el-collapse-item>
</el-collapse>
</div>
</el-drawer>
<el-dialog v-model="analysisDialogVisible" width="min(1180px, 96vw)" class="security-analysis-dialog">
<template #header>
<div class="security-log-dialog-head">
<strong>异常分析</strong>
<div class="security-log-dialog-actions">
<el-tag effect="plain" type="danger">高危 {{ formatNumber(securitySummary.high_risk_count) }}</el-tag>
<el-button size="small" type="primary" @click="jumpToSecurityEvents">查看事件明细</el-button>
</div>
</div>
</template>
<div class="analysis-ranking-dialog">
<div class="analysis-ranking-head">
<el-tag effect="plain" size="small" type="info">TOP {{ ipRiskStats.length }}</el-tag>
<div class="endpoint-summary-chips">
<span v-for="item in endpointTypeStats" :key="item.type">
{{ item.type }} <strong>{{ formatNumber(item.total) }}</strong>
</span>
</div>
</div>
<div v-if="ipRiskStats.length" class="analysis-rank-grid">
<article
v-for="(item, index) in ipRiskStats"
:key="item.ip"
class="analysis-rank-card"
:class="{ 'is-high-risk': item.highRiskCount > 0 }"
>
<div class="analysis-rank-card-head">
<span class="rank-no" :class="{ 'rank-top': index < 3 }">{{ String(index + 1).padStart(2, "0") }}</span>
<el-tag size="small" :type="item.highRiskCount > 0 ? 'danger' : 'info'" effect="plain">
{{ item.categoryText }}
</el-tag>
</div>
<div class="analysis-rank-card-ip">
<strong>{{ item.ip }}</strong>
<span>{{ item.location }}</span>
</div>
<div class="analysis-rank-card-meta">最后访问 {{ formatTime(item.lastSeenAt) }}</div>
<div class="analysis-rank-card-stats">
<div>
<span>事件</span>
<strong>{{ formatNumber(item.total) }}</strong>
</div>
<div>
<span>高危</span>
<strong :class="{ 'denied-highlight': item.highRiskCount > 0 }">{{ formatNumber(item.highRiskCount) }}</strong>
</div>
</div>
</article>
</div>
<el-empty v-else description="暂无异常数据" :image-size="72" />
</div>
</el-dialog>
<el-dialog v-model="securityLogDialogVisible" title="安全日志" width="82%" @opened="scrollSecurityTerminalToBottom">
<template #header>
<div class="security-log-dialog-head">
<strong>安全日志</strong>
<div class="security-log-dialog-actions">
<el-tag effect="plain" type="danger">共 {{ formatNumber(securityTotal) }} 条</el-tag>
<el-button size="small" type="primary" @click="downloadSecurityLog">下载日志</el-button>
</div>
</div>
</template>
<div ref="securityTerminalWindowRef" v-loading="securityLogDialogLoading || loading" class="terminal-window dialog-terminal-window">
<el-alert v-if="securityLogDialogError" :title="securityLogDialogError" type="warning" show-icon :closable="false" />
<pre v-if="securityTerminalLines.length" class="terminal-lines"><code>{{ securityTerminalLines.join("\n") }}</code></pre>
<el-empty v-else description="暂无安全日志" :image-size="72" />
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, h, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { Search as SearchIcon, Histogram as HistogramIcon, Document as DocumentIcon } from "@element-plus/icons-vue";
import { fetchSecurityAccessLogs } from "@/api/projectPermissions";
import TimeRangeSelector from "./TimeRangeSelector.vue";
import type { SecurityAccessLogItem, SecurityAccessLogsResponse } from "@/types/api";
import { saveFileWithFeedback } from "@/utils/fileTaskFeedback";
type MetricTone = "blue" | "cyan" | "danger" | "indigo";
type TimeRangePreset = "all" | "24h" | "7d" | "30d";
const MetricIconShield = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("path", { d: "M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" }),
]);
const MetricIconIp = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("circle", { cx: "12", cy: "10", r: "3" }),
h("path", { d: "M12 21s7-5.2 7-11a7 7 0 1 0-14 0c0 5.8 7 11 7 11z" }),
]);
const MetricIconAlert = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("path", { d: "M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" }),
h("line", { x1: "12", y1: "9", x2: "12", y2: "13" }),
h("line", { x1: "12", y1: "17", x2: "12.01", y2: "17" }),
]);
const MetricIconServer = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("rect", { x: "3", y: "4", width: "18", height: "8", rx: "2" }),
h("rect", { x: "3", y: "12", width: "18", height: "8", rx: "2" }),
h("line", { x1: "7", y1: "8", x2: "7.01", y2: "8" }),
h("line", { x1: "7", y1: "16", x2: "7.01", y2: "16" }),
]);
const loading = ref(false);
const statsEvents = ref<SecurityAccessLogItem[]>([]);
const allSecurityEvents = ref<SecurityAccessLogItem[]>([]);
const securityTotal = ref(0);
const emptySecuritySummary: SecurityAccessLogsResponse["summary"] = {
total_count: 0,
anonymous_count: 0,
invalid_token_count: 0,
error_count: 0,
unique_ip_count: 0,
high_risk_count: 0,
server_error_count: 0,
category_counts: {},
severity_counts: {},
endpoint_type_counts: {},
top_ips: [],
};
const securitySummary = ref<SecurityAccessLogsResponse["summary"]>({ ...emptySecuritySummary });
const page = ref(1);
const pageSize = ref(10);
const timeRangePreset = ref<TimeRangePreset>("24h");
const keyword = ref("");
const severityFilter = ref("");
const categoryFilter = ref("");
const authFilter = ref("");
const detailVisible = ref(false);
const selectedEvent = ref<SecurityAccessLogItem | null>(null);
const analysisDialogVisible = ref(false);
const securityLogDialogVisible = ref(false);
const securityLogDialogLoading = ref(false);
const securityTerminalWindowRef = ref<HTMLElement | null>(null);
const securityEventsSectionRef = ref<HTMLElement | null>(null);
const keywordTimer = ref<number | null>(null);
const loadError = ref("");
const securityLogDialogError = ref("");
const lastUpdatedAt = ref<Date | null>(null);
const severityOptions = [
{ label: "严重", value: "CRITICAL", tone: "critical" },
{ label: "高", value: "HIGH", tone: "high" },
{ label: "中", value: "MEDIUM", tone: "medium" },
{ label: "低", value: "LOW", tone: "low" },
];
const timeRangeOptions: Array<{ value: TimeRangePreset; label: string; hours: number | null }> = [
{ 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 },
];
const categoryOptions = [
{ label: "敏感路径探测", value: "PROBE" },
{ label: "无效令牌", value: "INVALID_TOKEN" },
{ label: "服务异常", value: "SERVER_ERROR" },
{ label: "匿名 API", value: "ANONYMOUS_API" },
{ label: "普通 404", value: "NOT_FOUND_NOISE" },
{ label: "其他", value: "OTHER" },
];
const severityLabel = (value: string) => severityOptions.find((item) => item.value === value)?.label || value;
const categoryLabel = (value: string) => categoryOptions.find((item) => item.value === value)?.label || value;
const authLabel = (value: string) => {
const labels: Record<string, string> = {
ANONYMOUS: "匿名",
INVALID_TOKEN: "无效令牌",
AUTHENTICATED: "已认证",
};
return labels[value] || value;
};
const severityTagType = (value: string) => {
if (value === "CRITICAL" || value === "HIGH") return "danger";
if (value === "MEDIUM") return "warning";
return "info";
};
const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
const formatTime = (value: string) => new Date(value).toLocaleString("zh-CN", { hour12: false });
const 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 fallbackIpLocation = (ip: string | null) => {
if (!ip) return "未知位置";
if (ip === "127.0.0.1" || ip === "::1") return "本机访问";
if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[0-1])\.)/.test(ip)) return "内网地址";
return "未知位置";
};
const formatIpLocation = (row: SecurityAccessLogItem) => {
const parts = [
row.ip_country && row.ip_country !== "中国" ? row.ip_country : "",
row.ip_province,
row.ip_city,
row.ip_isp,
].filter(Boolean);
if (parts.length) return parts.join(" / ");
return row.ip_location || fallbackIpLocation(row.client_ip);
};
const 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 securityClientSourceLabel = (event: SecurityAccessLogItem) => {
const clientType = String(event.client_type || "").toLowerCase();
if (clientType === "web") return "网页端";
if (clientType === "desktop") return "桌面端";
if (isScriptLikeUserAgent(event.user_agent)) return "脚本/自动化";
return "未知/异常";
};
const securityClientMeta = (event: SecurityAccessLogItem) => {
const parts = [event.client_type, event.client_version, event.client_platform].filter(Boolean);
if (parts.length) return parts.join(" / ");
return event.user_agent ? "未标识客户端" : "未记录来源";
};
const securityBuildMeta = (event: SecurityAccessLogItem) =>
[event.build_channel, event.build_commit].filter(Boolean).join(" / ") || "未记录";
const securityRequestSnapshot = (event: SecurityAccessLogItem) => event.request_snapshot || {};
const hasSecurityRequestSnapshot = (event: SecurityAccessLogItem) => Object.keys(securityRequestSnapshot(event)).length > 0;
const securityRequestHeaderEntries = (event: SecurityAccessLogItem) =>
Object.entries(securityRequestSnapshot(event).headers || event.request_headers || {}).sort(([left], [right]) => left.localeCompare(right));
const securityRequestMethod = (event: SecurityAccessLogItem) => securityRequestSnapshot(event).method || event.method || "HTTP";
const securityRequestPath = (event: SecurityAccessLogItem) => securityRequestSnapshot(event).path || event.path || "-";
const securityRequestQueryString = (event: SecurityAccessLogItem) => securityRequestSnapshot(event).query_string || "";
const securityRequestTarget = (event: SecurityAccessLogItem) => {
const path = securityRequestPath(event);
const query = securityRequestQueryString(event);
if (!query || path.includes("?")) return path;
return `${path}?${query}`;
};
const securityRequestOverviewFields = (event: SecurityAccessLogItem) => [
{ label: "时间", value: formatTime(event.created_at) },
{ label: "接口类型", value: resolveEndpointType(event.path) },
{ label: "事件分类", value: categoryLabel(event.category) },
{ label: "认证状态", value: authLabel(event.auth_status) },
{ label: "风险等级", value: severityLabel(event.severity) },
];
const securityAuditMetadataFields = (event: SecurityAccessLogItem) => [
{ label: "日志ID", value: event.id },
{ label: "类型", value: "安全事件" },
{ label: "账号标识", value: event.user_identifier || "未知" },
];
const securityRequestSnapshotFields = (event: SecurityAccessLogItem) => {
if (!hasSecurityRequestSnapshot(event)) {
return [
{ label: "采集状态", value: "历史日志未采集原始请求快照" },
{ label: "可用请求信息", value: `${securityRequestMethod(event)} ${securityRequestTarget(event)}`.trim() },
{ label: "说明", value: "该记录创建于请求快照能力上线前,无法还原 Method/Path/Query/Headers 原文" },
{ label: "Body", value: "未采集明文" },
];
}
const snapshot = securityRequestSnapshot(event);
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 securityRawRequestBodyLine = (event: SecurityAccessLogItem) => {
const body = securityRequestSnapshot(event).body || {};
if (body.captured) return "[request body captured]";
return `[request body not captured: ${body.reason || "body_not_captured_by_audit_policy"}]`;
};
const buildSecurityRawRequestBlock = (event: SecurityAccessLogItem) => {
if (!hasSecurityRequestSnapshot(event) && !event.request_headers) {
return [
"# 历史日志未采集原始 HTTP 请求",
`# request: ${securityRequestMethod(event)} ${securityRequestTarget(event)}`,
`# created_at: ${formatTime(event.created_at)}`,
"# Method/Path 为结构化安全事件字段,Headers 与 Body 无法追溯",
].join("\n");
}
const snapshot = securityRequestSnapshot(event);
const version = snapshot.http_version ? `HTTP/${snapshot.http_version}` : "HTTP";
const lines = [`${securityRequestMethod(event)} ${securityRequestTarget(event)} ${version}`.trim()];
const headers = securityRequestHeaderEntries(event);
if (headers.length) {
headers.forEach(([name, value]) => lines.push(`${name}: ${value}`));
} else if (event.user_agent) {
lines.push(`user-agent: ${event.user_agent}`);
}
lines.push("");
lines.push(securityRawRequestBodyLine(event));
return lines.join("\n");
};
const resolveEndpointType = (path: string) => {
const normalizedPath = path.toLowerCase();
if (normalizedPath.includes("/auth") || normalizedPath.includes("/login")) return "认证接口";
if (normalizedPath.startsWith("/api/")) return "业务 API";
if (normalizedPath.match(/\.(js|css|png|jpg|jpeg|svg|ico|map)$/)) return "静态资源";
if ([".env", "wp-", "php", "admin", "shell", "config"].some((marker) => normalizedPath.includes(marker))) return "探测路径";
return "其他请求";
};
const buildSecurityTerminalLine = (event: SecurityAccessLogItem) => {
const ip = event.client_ip || "未知 IP";
const account = event.account_label || "未知账号";
const auth = `${event.auth_status}/${authLabel(event.auth_status)}`;
const client = [event.client_type, event.client_version, event.client_platform].filter(Boolean).join("/") || "unknown";
const userAgent = event.user_agent || "-";
return `${formatTime(event.created_at)} [${severityLabel(event.severity)}:${categoryLabel(event.category)}] status=${event.status_code} auth=${auth} account=${account} client=${client} ip=${ip} loc=${formatIpLocation(event)} method=${securityRequestMethod(event)} path=${securityRequestTarget(event)} ua=${userAgent} cost=${event.elapsed_ms.toFixed(1)}ms`;
};
const securityTerminalLines = computed(() =>
[...allSecurityEvents.value]
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
.map(buildSecurityTerminalLine),
);
const paginatedEvents = computed(() => statsEvents.value);
const metricCards = computed<Array<{ label: string; value: string; tone: MetricTone; icon: any }>>(() => {
const highRiskCount = securitySummary.value.high_risk_count;
const serverErrorCount = securitySummary.value.server_error_count;
return [
{ label: "安全事件", value: formatNumber(securitySummary.value.total_count), tone: "blue", icon: MetricIconShield },
{ label: "风险来源 IP", value: formatNumber(securitySummary.value.unique_ip_count), tone: "cyan", icon: MetricIconIp },
{ label: "高危风险", value: formatNumber(highRiskCount), tone: highRiskCount > 0 ? "danger" : "indigo", icon: MetricIconAlert },
{ label: "服务异常", value: formatNumber(serverErrorCount), tone: serverErrorCount > 0 ? "danger" : "indigo", icon: MetricIconServer },
];
});
const ipRiskStats = computed(() => {
return securitySummary.value.top_ips.map((item) => ({
ip: item.ip,
location: item.location || "未知位置",
categoryText: categoryLabel(item.category),
total: item.total_count,
highRiskCount: item.high_risk_count,
lastSeenAt: item.last_seen_at || "",
}));
});
const topRiskPreview = computed(() => ipRiskStats.value.slice(0, 3));
const endpointTypeStats = computed(() => {
const items = Object.entries(securitySummary.value.endpoint_type_counts).map(([type, value]) => ({
type,
total: value.count,
samplePath: value.sample_path,
}));
const maxTotal = Math.max(1, ...items.map((item) => item.total));
return items
.sort((a, b) => b.total - a.total)
.map((item) => ({ ...item, percent: Math.max(8, Math.round((item.total / maxTotal) * 100)) }));
});
const riskLevelStats = computed(() => {
const filteredCounts = severityOptions.map((item) => ({
...item,
total: securitySummary.value.severity_counts[item.value] || 0,
}));
const maxTotal = Math.max(1, ...filteredCounts.map((item) => item.total));
return filteredCounts.map((item) => {
return {
...item,
percent: item.total > 0 ? Math.max(8, Math.round((item.total / maxTotal) * 100)) : 0,
};
});
});
const SECURITY_EXPORT_PAGE_SIZE = 200;
const buildTimeRangeFromPreset = () => {
const option = timeRangeOptions.find((item) => item.value === timeRangePreset.value);
if (!option?.hours) return {};
const end = new Date();
const start = new Date(end.getTime() - option.hours * 60 * 60 * 1000);
return { start_time: start.toISOString(), end_time: end.toISOString() };
};
const securityQueryParams = (overrides: Record<string, unknown> = {}) => ({
events_only: true,
auth_status: authFilter.value || undefined,
category: categoryFilter.value || undefined,
severity: severityFilter.value || undefined,
keyword: keyword.value.trim() || undefined,
page: page.value,
page_size: pageSize.value,
...buildTimeRangeFromPreset(),
...overrides,
});
const loadSecurityEvents = async () => {
loading.value = true;
try {
await loadSecurityStats();
loadError.value = "";
lastUpdatedAt.value = new Date();
} catch {
loadError.value = "安全事件加载失败,页面中可能是上次成功获取的数据";
} finally {
loading.value = false;
}
};
const loadSecurityStats = async () => {
const response = await fetchSecurityAccessLogs(securityQueryParams());
statsEvents.value = response.data.items;
securityTotal.value = response.data.total;
securitySummary.value = response.data.summary;
};
const fetchAllSecurityEvents = async () => {
const first = await fetchSecurityAccessLogs(
securityQueryParams({ page: 1, page_size: SECURITY_EXPORT_PAGE_SIZE }),
);
const items = [...first.data.items];
const totalPages = Math.ceil(first.data.total / SECURITY_EXPORT_PAGE_SIZE);
for (let nextPage = 2; nextPage <= totalPages; nextPage += 1) {
const response = await fetchSecurityAccessLogs(
securityQueryParams({ page: nextPage, page_size: SECURITY_EXPORT_PAGE_SIZE }),
);
items.push(...response.data.items);
}
return items;
};
const scrollSecurityTerminalToBottom = () => {
nextTick(() => {
const terminal = securityTerminalWindowRef.value;
if (!terminal) return;
terminal.scrollTop = terminal.scrollHeight;
});
};
const openSecurityLogDialog = async () => {
securityLogDialogVisible.value = true;
securityLogDialogLoading.value = true;
securityLogDialogError.value = "";
try {
allSecurityEvents.value = await fetchAllSecurityEvents();
scrollSecurityTerminalToBottom();
} catch {
securityLogDialogError.value = "安全日志加载失败,请稍后重试";
} finally {
securityLogDialogLoading.value = false;
}
};
const downloadSecurityLog = async () => {
const blob = new Blob([`${securityTerminalLines.value.join("\n")}\n`], { type: "text/plain;charset=utf-8" });
await saveFileWithFeedback(
{ suggestedName: "security-access-logs.log", mimeType: "text/plain;charset=utf-8", data: blob },
{
kind: "export",
title: "导出安全日志",
completedDetail: "安全日志文件已保存",
},
);
};
const onFilterChange = () => {
page.value = 1;
loadSecurityEvents();
};
const onTimeRangeChange = () => {
page.value = 1;
loadSecurityEvents();
};
const onPageSizeChange = (size: number) => {
pageSize.value = size;
page.value = 1;
loadSecurityEvents();
};
watch(keyword, () => {
page.value = 1;
if (keywordTimer.value) window.clearTimeout(keywordTimer.value);
keywordTimer.value = window.setTimeout(() => {
keywordTimer.value = null;
loadSecurityEvents();
}, 350);
});
const openDetail = (event: SecurityAccessLogItem) => {
selectedEvent.value = event;
detailVisible.value = true;
};
const openAnalysisDialog = () => {
analysisDialogVisible.value = true;
};
const jumpToSecurityEvents = async () => {
analysisDialogVisible.value = false;
await nextTick();
securityEventsSectionRef.value?.scrollIntoView({ behavior: "smooth", block: "start" });
};
onMounted(loadSecurityEvents);
onBeforeUnmount(() => {
if (keywordTimer.value) window.clearTimeout(keywordTimer.value);
});
defineExpose({ refresh: loadSecurityEvents });
</script>
<style scoped>
.security-center {
--audit-ink: #1a2332;
--audit-muted: #64748b;
--audit-border: #e2e8f0;
--audit-blue: #3b82f6;
--audit-cyan: #06b6d4;
--audit-danger: #ef4444;
--audit-indigo: #6366f1;
--card-radius: 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;
}
.security-overview {
padding: 8px 12px 6px;
border: 1px solid var(--audit-border);
border-radius: var(--card-radius);
background: #fff;
box-shadow: var(--card-shadow);
}
.security-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 32px;
padding: 0 0 7px;
border-bottom: 1px solid #edf1f6;
}
.security-toolbar-title h3 {
margin: 0;
color: var(--audit-ink);
font-size: 15px;
font-weight: 650;
}
.security-toolbar-title p {
margin: 3px 0 0;
color: var(--audit-muted);
font-size: 12px;
}
.security-toolbar-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.audit-filters {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
padding: 14px 18px;
background: #fff;
border-radius: var(--card-radius);
border: 1px solid var(--audit-border);
box-shadow: var(--card-shadow);
}
.security-search {
width: 280px;
}
.audit-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 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.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.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;
}
.security-analysis-card {
min-width: 0;
padding: 10px 12px;
border: 1px solid var(--audit-border);
border-radius: var(--card-radius);
background: #fff;
box-shadow: var(--card-shadow);
}
.analysis-dialog-panel {
min-width: 0;
padding: 20px;
border: 1px solid var(--audit-border);
border-radius: var(--card-radius);
background: #fff;
box-shadow: var(--card-shadow);
}
.security-table-wrap {
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);
}
.security-table {
--el-table-header-bg-color: #f8fafc;
font-size: 12px;
}
.security-table :deep(.el-table__cell) {
padding: 5px 0;
}
.security-table :deep(.el-table__header .cell) {
color: #64748b;
font-size: 11.5px;
font-weight: 600;
}
.security-table :deep(.cell) {
padding: 0 8px;
}
.security-table :deep(.el-scrollbar__wrap) {
overflow-x: hidden;
}
.security-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;
}
.analysis-summary-head {
align-items: center;
margin-bottom: 6px;
}
.analysis-summary-actions {
display: flex;
align-items: center;
gap: 8px;
}
.analysis-summary-body {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(300px, 0.8fr);
gap: 8px;
align-items: stretch;
}
.risk-preview-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.risk-preview-row {
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
min-height: 36px;
padding: 4px 6px;
border-radius: 8px;
}
.risk-preview-row:hover {
background: #f8fafc;
}
.risk-preview-main,
.risk-preview-count {
display: flex;
min-width: 0;
}
.risk-preview-main {
align-items: baseline;
gap: 8px;
}
.risk-preview-main strong {
flex: 0 0 auto;
color: var(--audit-ink);
font-size: 13px;
font-weight: 650;
}
.risk-preview-main span {
overflow: hidden;
color: var(--audit-muted);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.risk-preview-count {
align-items: flex-end;
flex-direction: column;
gap: 1px;
}
.risk-preview-count strong {
color: var(--audit-ink);
font-size: 16px;
}
.risk-preview-count small {
color: var(--audit-muted);
font-size: 11px;
}
.risk-preview-count .denied-highlight {
color: var(--audit-danger);
font-weight: 600;
}
.risk-summary-panel {
display: flex;
flex-direction: column;
justify-content: center;
gap: 7px;
padding-left: 12px;
border-left: 1px solid var(--audit-border);
}
.risk-summary-title {
color: var(--audit-ink);
font-size: 13px;
font-weight: 650;
}
.analysis-dialog-grid {
display: grid;
grid-template-columns: minmax(340px, 1fr) minmax(0, 1.15fr);
gap: 16px;
}
.analysis-dialog-panel {
box-shadow: none;
}
.analysis-ranking-dialog {
display: flex;
flex-direction: column;
gap: 12px;
}
.analysis-ranking-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: var(--audit-muted);
font-size: 12px;
}
.endpoint-summary-chips {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 6px;
}
.endpoint-summary-chips span {
padding: 5px 9px;
border-radius: 999px;
background: #f8fafc;
color: var(--audit-muted);
}
.endpoint-summary-chips strong {
margin-left: 3px;
color: var(--audit-ink);
}
.analysis-rank-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
}
.analysis-rank-card {
display: flex;
min-width: 0;
flex-direction: column;
gap: 8px;
padding: 10px;
border: 1px solid #dbeafe;
border-radius: 8px;
background: #f8fafc;
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
}
.analysis-rank-card.is-high-risk {
border-color: #fecaca;
background: #fff7f7;
}
.analysis-rank-card:hover {
border-color: #cbd5e1;
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.06);
transform: translateY(-1px);
}
.analysis-rank-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
}
.analysis-rank-card-ip {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.analysis-rank-card-ip strong,
.analysis-rank-card-ip span,
.analysis-rank-card-meta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.analysis-rank-card-ip strong {
color: var(--audit-ink);
font-size: 13.5px;
font-weight: 700;
}
.analysis-rank-card-ip span,
.analysis-rank-card-meta {
color: var(--audit-muted);
font-size: 10.5px;
}
.analysis-rank-card-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.analysis-rank-card-stats div {
min-width: 0;
padding: 6px 7px;
border-radius: 7px;
background: rgba(248, 250, 252, 0.9);
}
.analysis-rank-card-stats span {
display: block;
color: var(--audit-muted);
font-size: 11px;
}
.analysis-rank-card-stats strong {
display: block;
margin-top: 2px;
color: var(--audit-ink);
font-size: 15px;
line-height: 1.1;
}
.analysis-rank-card-stats .denied-highlight {
color: var(--audit-danger);
}
.section-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.table-head {
align-items: center;
flex-wrap: wrap;
margin-bottom: 0;
padding: 10px 12px;
border-bottom: 1px solid var(--audit-border);
}
.table-head .section-title-group {
flex-shrink: 0;
}
.table-head .el-tag {
margin-left: auto;
flex-shrink: 0;
}
.detail-filters {
flex: 1;
min-width: 0;
padding: 0;
border: 0;
box-shadow: none;
}
.section-title-group h4 {
margin: 0;
color: var(--audit-ink);
font-size: 16px;
font-weight: 600;
}
.section-title-group p {
margin: 4px 0 0;
color: var(--audit-muted);
font-size: 12px;
}
.risk-rank-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.risk-rank-row {
display: grid;
grid-template-columns: 36px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
padding: 12px 10px;
border-radius: 10px;
transition: background 0.15s ease;
}
.risk-rank-row:hover {
background: #f8fafc;
}
.rank-no {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
background: #f1f5f9;
color: #94a3b8;
font-size: 12px;
font-weight: 700;
}
.rank-no.rank-top {
background: linear-gradient(135deg, #ef4444, #6366f1);
color: #fff;
}
.rank-user,
.rank-count {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.rank-main-line,
.rank-meta-line {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.rank-main-line strong {
flex-shrink: 0;
overflow: hidden;
color: var(--audit-ink);
font-size: 14px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.rank-main-line small {
overflow: hidden;
color: var(--audit-muted);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.rank-meta-line span {
color: var(--audit-muted);
font-size: 12px;
}
.rank-count {
align-items: flex-end;
}
.rank-count strong {
color: var(--audit-ink);
font-size: 18px;
font-weight: 700;
}
.rank-count small {
color: var(--audit-muted);
font-size: 11px;
}
.rank-count .denied-highlight {
color: var(--audit-danger);
font-weight: 600;
}
.distribution-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.risk-level-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.event-distribution-card {
display: flex;
flex-direction: column;
gap: 22px;
}
.distribution-section {
display: flex;
flex-direction: column;
gap: 14px;
}
.distribution-section + .distribution-section {
padding-top: 24px;
border-top: 1px solid var(--audit-border);
}
.event-distribution-card > .section-head {
margin-bottom: 0;
}
.event-distribution-card .section-title-group h4 {
font-size: 16px;
}
.distribution-section-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.distribution-section-head strong {
color: var(--audit-ink);
font-size: 15px;
font-weight: 700;
}
.distribution-row {
display: grid;
grid-template-columns: minmax(160px, 0.9fr) minmax(120px, 1fr) 44px;
gap: 12px;
align-items: center;
}
.distribution-info {
min-width: 0;
}
.distribution-info strong {
display: block;
color: var(--audit-ink);
font-size: 14px;
font-weight: 600;
}
.distribution-info small {
display: block;
overflow: hidden;
margin-top: 3px;
color: var(--audit-muted);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.distribution-meter,
.risk-level-meter {
overflow: hidden;
height: 8px;
border-radius: 999px;
background: #f1f5f9;
}
.distribution-meter span {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #06b6d4, #3b82f6);
}
.distribution-count {
color: var(--audit-ink);
font-size: 16px;
text-align: right;
}
.risk-level-row {
display: grid;
grid-template-columns: 10px 52px minmax(0, 1fr) 44px;
gap: 10px;
align-items: center;
}
.risk-dot {
width: 8px;
height: 8px;
border-radius: 999px;
}
.risk-dot.critical,
.risk-level-meter span.critical { background: #991b1b; }
.risk-dot.high,
.risk-level-meter span.high { background: #ef4444; }
.risk-dot.medium,
.risk-level-meter span.medium { background: #f59e0b; }
.risk-dot.low,
.risk-level-meter span.low { background: #64748b; }
.risk-level-row > span:nth-child(2) {
color: var(--audit-ink);
font-size: 13px;
font-weight: 600;
}
.risk-level-meter span {
display: block;
height: 100%;
border-radius: inherit;
}
.risk-level-row strong {
color: var(--audit-ink);
font-size: 14px;
text-align: right;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
margin-top: 0;
padding: 8px 12px;
border-top: 1px solid var(--audit-border);
}
.category-pill {
display: inline-flex;
align-items: center;
max-width: 112px;
padding: 3px 8px;
border-radius: 999px;
background: #eef2f7;
color: #334155;
font-size: 12px;
font-weight: 600;
}
.request-cell {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.request-cell strong {
font-size: 12px;
color: #475569;
}
.request-cell span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--audit-ink);
}
.security-detail-head {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
}
.security-log-dialog-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
}
.security-log-dialog-head strong {
color: var(--audit-ink);
font-size: 17px;
}
.analysis-dialog-title {
display: flex;
flex-direction: column;
gap: 3px;
}
.analysis-dialog-title small {
color: var(--audit-muted);
font-size: 12px;
}
.security-log-dialog-actions {
display: flex;
align-items: center;
gap: 8px;
}
.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;
}
.security-detail,
.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;
}
.security-analysis-card .risk-level-list {
gap: 5px;
}
.security-analysis-card .risk-level-row {
min-height: 18px;
}
@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;
}
.analysis-summary-body,
.analysis-dialog-grid {
grid-template-columns: 1fr;
}
.analysis-rank-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.risk-summary-panel {
padding-top: 14px;
padding-left: 0;
border-top: 1px solid var(--audit-border);
border-left: 0;
}
}
@media (max-width: 900px) {
.analysis-rank-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.security-toolbar,
.security-toolbar-actions {
align-items: flex-start;
flex-direction: column;
}
.security-toolbar-actions {
width: 100%;
}
.analysis-ranking-head {
align-items: flex-start;
flex-direction: column;
}
.endpoint-summary-chips {
justify-content: flex-start;
}
.analysis-rank-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.audit-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.security-analysis-card {
padding: 10px;
}
.analysis-summary-head,
.analysis-summary-actions,
.risk-preview-main {
align-items: flex-start;
}
.analysis-summary-head,
.risk-preview-main {
flex-direction: column;
}
.analysis-summary-actions {
width: 100%;
justify-content: space-between;
}
.risk-preview-main {
gap: 2px;
}
.risk-preview-main span {
width: 100%;
}
.security-search {
width: 100%;
}
.detail-grid,
.detail-field-grid {
grid-template-columns: 1fr;
}
.distribution-row {
grid-template-columns: 1fr 44px;
}
.distribution-meter {
grid-column: 1 / -1;
grid-row: 2;
}
}
@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;
}
.analysis-rank-grid {
grid-template-columns: 1fr;
}
}
</style>