d5279b124f
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
1214 lines
33 KiB
Vue
1214 lines
33 KiB
Vue
<template>
|
|
<div class="ip-locations">
|
|
<section class="ip-overview">
|
|
<section class="ip-hero">
|
|
<div class="ip-locations-toolbar">
|
|
<div class="toolbar-left">
|
|
<span class="toolbar-title">访问来源分析</span>
|
|
</div>
|
|
<div class="toolbar-actions">
|
|
<TimeRangeSelector v-model="timeRangePreset" @change="loadData" />
|
|
<span v-if="lastUpdatedAt" class="ctms-updated-at">更新于 {{ formatLastUpdated(lastUpdatedAt) }}</span>
|
|
<el-button :loading="loading" size="small" :disabled="loading" @click="loadData">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 14px; height: 14px; margin-right: 4px"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
|
刷新
|
|
</el-button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="metric-grid" 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" />
|
|
|
|
<section class="distribution-card">
|
|
<div class="map-panel">
|
|
<div class="section-head">
|
|
<div class="section-title-group">
|
|
<h4>来源分布</h4>
|
|
</div>
|
|
<div class="map-head-actions">
|
|
<el-segmented v-model="mapMetric" :options="mapMetricOptions" size="small" />
|
|
<el-segmented v-model="mapView" :options="mapViewOptions" size="small" />
|
|
<el-tag type="info" effect="plain" round size="small">{{ currentPeriodLabel }}</el-tag>
|
|
<el-tag v-if="dataQuality && dataQuality.unknown_ip_count" type="warning" effect="plain" round size="small">
|
|
定位 {{ dataQuality.location_coverage_rate }}%
|
|
</el-tag>
|
|
<el-tag v-if="dataQuality?.external_fallback_ip_count" type="info" effect="plain" round size="small">
|
|
外部补全 {{ dataQuality.external_fallback_ip_count }} IP
|
|
</el-tag>
|
|
<el-tooltip content="全屏预览" placement="top">
|
|
<el-button class="map-fullscreen-button" size="small" circle aria-label="全屏预览" @click="openFullscreenPreview">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3H3v5"/><path d="M16 3h5v5"/><path d="M21 16v5h-5"/><path d="M3 16v5h5"/><path d="M3 3l6 6"/><path d="M21 3l-6 6"/><path d="M21 21l-6-6"/><path d="M3 21l6-6"/></svg>
|
|
</el-button>
|
|
</el-tooltip>
|
|
</div>
|
|
</div>
|
|
<div class="source-map-wrap">
|
|
<template v-if="!fullscreenVisible">
|
|
<v-chart
|
|
v-if="mapReady"
|
|
ref="sourceChartRef"
|
|
:key="mapView"
|
|
:option="sourceMapOption"
|
|
:init-options="chartInitOptions"
|
|
:update-options="chartUpdateOptions"
|
|
:autoresize="chartAutoresize"
|
|
class="source-map"
|
|
/>
|
|
<el-empty v-else-if="mapLoadError" :description="mapLoadError" :image-size="72" />
|
|
<div v-else v-loading="true" class="source-map" aria-label="地图加载中" />
|
|
</template>
|
|
<div class="flow-legend map-legend" aria-label="访问链路图例">
|
|
<span v-if="serverLocation" class="legend-item"><i class="legend-dot server"></i>服务器</span>
|
|
<template v-if="mapMetric === 'traffic'">
|
|
<span class="legend-item"><i class="legend-dot traffic"></i>访问来源</span>
|
|
</template>
|
|
<template v-else>
|
|
<span class="legend-item"><i class="legend-dot normal"></i>正常</span>
|
|
<span class="legend-item"><i class="legend-dot attention"></i>关注</span>
|
|
<span class="legend-item"><i class="legend-dot high"></i>高风险</span>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<aside class="rank-panel">
|
|
<div class="section-head compact">
|
|
<div class="section-title-group">
|
|
<h4>热点来源地</h4>
|
|
</div>
|
|
</div>
|
|
<div v-if="topRegions.length" class="region-list">
|
|
<div v-for="region in topRegions" :key="region.key" class="region-row">
|
|
<span class="rank" :class="{ top: Number(region.rank) <= 3 }">{{ region.rank }}</span>
|
|
<span class="region-name"><i class="region-risk-dot" :class="region.risk_level"></i>{{ region.name }}</span>
|
|
<span class="region-value">{{ region.total_count }}</span>
|
|
</div>
|
|
</div>
|
|
<el-empty v-else description="暂无属地数据" :image-size="72" />
|
|
</aside>
|
|
</section>
|
|
|
|
<el-dialog
|
|
v-model="fullscreenVisible"
|
|
class="source-map-dialog"
|
|
modal-class="source-map-overlay"
|
|
fullscreen
|
|
destroy-on-close
|
|
:show-close="false"
|
|
:close-on-click-modal="false"
|
|
append-to-body
|
|
@opened="resizeFullscreenChart"
|
|
@close="fullscreenChartMounted = false"
|
|
@closed="resizeSourceCharts"
|
|
>
|
|
<template #header>
|
|
<div class="fullscreen-map-header">
|
|
<div class="fullscreen-title-group">
|
|
<h3>来源分布全屏预览</h3>
|
|
<span>{{ currentPeriodLabel }}</span>
|
|
</div>
|
|
<div class="fullscreen-actions">
|
|
<el-segmented v-model="mapMetric" :options="mapMetricOptions" size="small" />
|
|
<el-segmented v-model="mapView" :options="mapViewOptions" size="small" />
|
|
<el-button size="small" @click="closeFullscreenPreview">退出全屏</el-button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<div class="fullscreen-map-wrap">
|
|
<v-chart
|
|
v-if="fullscreenChartMounted && mapReady"
|
|
ref="fullscreenChartRef"
|
|
:key="fullscreenMapKey"
|
|
:option="fullscreenSourceMapOption"
|
|
:init-options="fullscreenChartInitOptions"
|
|
:update-options="chartUpdateOptions"
|
|
:autoresize="chartAutoresize"
|
|
class="fullscreen-source-map"
|
|
/>
|
|
<div class="flow-legend map-legend" aria-label="访问链路图例">
|
|
<span v-if="serverLocation" class="legend-item"><i class="legend-dot server"></i>服务器</span>
|
|
<template v-if="mapMetric === 'traffic'">
|
|
<span class="legend-item"><i class="legend-dot traffic"></i>访问来源</span>
|
|
</template>
|
|
<template v-else>
|
|
<span class="legend-item"><i class="legend-dot normal"></i>正常</span>
|
|
<span class="legend-item"><i class="legend-dot attention"></i>关注</span>
|
|
<span class="legend-item"><i class="legend-dot high"></i>高风险</span>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</el-dialog>
|
|
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, h, nextTick, onMounted, ref, watch } from "vue";
|
|
import VChart from "vue-echarts";
|
|
import { registerMap, use } from "echarts/core";
|
|
import { CanvasRenderer } from "echarts/renderers";
|
|
import { EffectScatterChart, LinesChart, ScatterChart } from "echarts/charts";
|
|
import { GeoComponent, TooltipComponent } from "echarts/components";
|
|
import { fetchIpLocations } from "../api/projectPermissions";
|
|
import type { IpLocationsResponse, IpLocationStatItem } from "../types/api";
|
|
import TimeRangeSelector from "./TimeRangeSelector.vue";
|
|
|
|
use([CanvasRenderer, LinesChart, ScatterChart, EffectScatterChart, GeoComponent, TooltipComponent]);
|
|
|
|
type MapView = "china" | "flow";
|
|
|
|
const mapDefinitions: Record<MapView, { name: string; load: () => Promise<{ default: unknown }> }> = {
|
|
china: { name: "ctms-china", load: () => import("../assets/china.json") },
|
|
flow: { name: "ctms-world", load: () => import("../assets/world.json") },
|
|
};
|
|
|
|
const registeredMaps = new Set<string>();
|
|
|
|
const IconGlobe = () => 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: "2", y1: "12", x2: "22", y2: "12" }),
|
|
h("path", { d: "M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" }),
|
|
]);
|
|
const IconUsers = () => 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 IconWifi = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
|
h("path", { d: "M5 12.55a11 11 0 0 1 14.08 0" }),
|
|
h("path", { d: "M1.42 9a16 16 0 0 1 21.16 0" }),
|
|
h("path", { d: "M8.53 16.11a6 6 0 0 1 6.95 0" }),
|
|
h("line", { x1: "12", y1: "20", x2: "12.01", y2: "20" }),
|
|
]);
|
|
const IconShield = () => 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 loading = ref(false);
|
|
type TimeRangePreset = "all" | "24h" | "7d" | "30d";
|
|
const timeRangePreset = ref<TimeRangePreset>("30d");
|
|
|
|
const getIpLocationsDays = (preset: TimeRangePreset): number | undefined => {
|
|
const map: Record<TimeRangePreset, number | undefined> = {
|
|
all: undefined,
|
|
"24h": 1,
|
|
"7d": 7,
|
|
"30d": 30,
|
|
};
|
|
return map[preset];
|
|
};
|
|
const mapView = ref<MapView>("flow");
|
|
const mapMetric = ref<"traffic" | "risk">("traffic");
|
|
const items = ref<IpLocationStatItem[]>([]);
|
|
const summary = ref<IpLocationsResponse["summary"] | null>(null);
|
|
const periodMetadata = ref<IpLocationsResponse["period"] | null>(null);
|
|
const dataQuality = ref<IpLocationsResponse["data_quality"] | null>(null);
|
|
const serverLocation = ref<IpLocationsResponse["server_location"]>(null);
|
|
const loadError = ref("");
|
|
const mapLoadError = ref("");
|
|
const mapReady = ref(false);
|
|
const lastUpdatedAt = ref<Date | null>(null);
|
|
const getDevicePixelRatio = () => (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1);
|
|
const chartInitOptions = { devicePixelRatio: Math.min(getDevicePixelRatio(), 2), useDirtyRect: true };
|
|
const fullscreenChartInitOptions = { devicePixelRatio: Math.min(getDevicePixelRatio(), 2), useDirtyRect: true };
|
|
const chartUpdateOptions = { notMerge: true };
|
|
const chartAutoresize = { throttle: 120 };
|
|
const fullscreenVisible = ref(false);
|
|
const fullscreenChartMounted = ref(false);
|
|
const sourceChartRef = ref<{ resize?: () => void } | null>(null);
|
|
const fullscreenChartRef = ref<{ resize?: () => void } | null>(null);
|
|
|
|
const periodLabels: Record<TimeRangePreset, string> = {
|
|
all: "全部",
|
|
"24h": "近 24 小时",
|
|
"7d": "近 7 天",
|
|
"30d": "近 30 天",
|
|
};
|
|
|
|
const currentPeriodLabel = computed(() => {
|
|
if (timeRangePreset.value === "all" && periodMetadata.value) {
|
|
return `全部(保留期 ${periodMetadata.value.retention_days} 天)`;
|
|
}
|
|
return periodLabels[timeRangePreset.value];
|
|
});
|
|
const mapMetricOptions = [
|
|
{ label: "访问热度", value: "traffic" },
|
|
{ label: "安全风险", value: "risk" },
|
|
];
|
|
const mapViewOptions = [
|
|
{ label: "中国", value: "china" },
|
|
{ label: "全球", value: "flow" },
|
|
];
|
|
const fullscreenMapKey = computed(() => `fullscreen-${mapView.value}-${mapMetric.value}-${fullscreenVisible.value ? "open" : "closed"}`);
|
|
|
|
const openFullscreenPreview = () => {
|
|
fullscreenVisible.value = true;
|
|
};
|
|
|
|
const closeFullscreenPreview = () => {
|
|
fullscreenChartMounted.value = false;
|
|
fullscreenVisible.value = false;
|
|
};
|
|
|
|
const resizeFullscreenChart = async () => {
|
|
fullscreenChartMounted.value = true;
|
|
await nextTick();
|
|
fullscreenChartRef.value?.resize?.();
|
|
};
|
|
|
|
const resizeSourceCharts = async () => {
|
|
await nextTick();
|
|
sourceChartRef.value?.resize?.();
|
|
};
|
|
|
|
let mapLoadSequence = 0;
|
|
|
|
const loadMap = async (view: MapView) => {
|
|
const sequence = ++mapLoadSequence;
|
|
const definition = mapDefinitions[view];
|
|
mapReady.value = false;
|
|
mapLoadError.value = "";
|
|
|
|
try {
|
|
if (!registeredMaps.has(definition.name)) {
|
|
const geoJson = await definition.load();
|
|
registerMap(definition.name, geoJson.default as any);
|
|
registeredMaps.add(definition.name);
|
|
}
|
|
if (sequence !== mapLoadSequence) return;
|
|
mapReady.value = true;
|
|
await resizeSourceCharts();
|
|
} catch {
|
|
if (sequence !== mapLoadSequence) return;
|
|
mapLoadError.value = "地图资源加载失败,请刷新后重试";
|
|
}
|
|
};
|
|
|
|
watch(mapView, (view) => void loadMap(view), { immediate: true });
|
|
|
|
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 formatLocation = (row: IpLocationStatItem) => {
|
|
const parts = [row.country_code !== "CN" ? row.country : "", row.province, row.city, row.isp].filter(Boolean);
|
|
return parts.length ? parts.join(" / ") : row.location;
|
|
};
|
|
|
|
const emptySummary: IpLocationsResponse["summary"] = {
|
|
total_count: 0,
|
|
allowed_count: 0,
|
|
denied_count: 0,
|
|
unique_ip_count: 0,
|
|
unique_user_count: 0,
|
|
security_event_count: 0,
|
|
high_risk_count: 0,
|
|
};
|
|
|
|
const activeSummary = computed(() => summary.value || emptySummary);
|
|
|
|
const deniedRate = computed(() => {
|
|
const total = activeSummary.value.total_count;
|
|
if (!total) return 0;
|
|
return Math.round((activeSummary.value.denied_count / total) * 1000) / 10;
|
|
});
|
|
|
|
const metricCards = computed(() => [
|
|
{
|
|
label: "访问次数",
|
|
value: formatNumber(activeSummary.value.total_count),
|
|
tone: "blue",
|
|
icon: IconGlobe,
|
|
},
|
|
{
|
|
label: "访问用户数",
|
|
value: formatNumber(activeSummary.value.unique_user_count),
|
|
tone: "cyan",
|
|
icon: IconUsers,
|
|
},
|
|
{
|
|
label: "来源 IP 数",
|
|
value: formatNumber(activeSummary.value.unique_ip_count),
|
|
tone: "indigo",
|
|
icon: IconWifi,
|
|
},
|
|
{
|
|
label: "拒绝占比",
|
|
value: `${deniedRate.value}%`,
|
|
tone: deniedRate.value > 10 ? "danger" : "violet",
|
|
icon: IconShield,
|
|
},
|
|
]);
|
|
|
|
const rankedSourceRows = computed(() => [...items.value].sort((a, b) => b.total_count - a.total_count).slice(0, 10));
|
|
|
|
const resolveSourceCoordinate = (item: IpLocationStatItem): [number, number] | null =>
|
|
typeof item.longitude === "number" && typeof item.latitude === "number"
|
|
? [item.longitude, item.latitude]
|
|
: null;
|
|
|
|
const isChinaSource = (item: IpLocationStatItem) => item.country_code === "CN" || item.country_code === "PRIVATE";
|
|
|
|
const chinaRankRows = computed(() => rankedSourceRows.value.filter((item) => isChinaSource(item)));
|
|
|
|
const chinaFlowSourceData = computed(() => chinaRankRows.value.filter((item) => resolveSourceCoordinate(item)));
|
|
|
|
const flowSourceData = computed(() => rankedSourceRows.value.filter((item) => resolveSourceCoordinate(item)));
|
|
|
|
const riskColor = (riskLevel: IpLocationStatItem["risk_level"]) => {
|
|
if (riskLevel === "high") return "#ef4444";
|
|
if (riskLevel === "attention") return "#f59e0b";
|
|
return "#2563eb";
|
|
};
|
|
|
|
const buildFlowMapOption = (data: IpLocationStatItem[], mapName = "ctms-world", zoom = 1.2, seriesName = "全球访问链路") => {
|
|
const server = serverLocation.value;
|
|
const serverCoord = server ? ([server.longitude, server.latitude] as [number, number]) : null;
|
|
const points = data
|
|
.map((item) => ({
|
|
...item,
|
|
name: formatLocation(item),
|
|
coord: resolveSourceCoordinate(item),
|
|
color: mapMetric.value === "risk" ? riskColor(item.risk_level) : "#2563eb",
|
|
}))
|
|
.filter((item): item is IpLocationStatItem & { name: string; coord: [number, number]; color: string } => Boolean(item.coord));
|
|
|
|
const formatLineTooltip = (params: any) => {
|
|
const row = params.data;
|
|
if (!row) return "";
|
|
return [
|
|
`${row.fromName} → ${row.toName}`,
|
|
`访问次数:${formatNumber(row.value || 0)}`,
|
|
`风险等级:${row.riskLevel === "high" ? "高风险" : row.riskLevel === "attention" ? "关注" : "正常"}`,
|
|
].join("<br/>");
|
|
};
|
|
|
|
const formatPointTooltip = (params: any) => {
|
|
const row = params.data;
|
|
if (!row) return "";
|
|
if (row.isServer) return server?.name || "服务器";
|
|
return [
|
|
`${row.name}`,
|
|
`访问次数:${formatNumber(row.total_count || 0)}`,
|
|
`访问用户数:${formatNumber(row.unique_user_count || 0)}`,
|
|
`来源 IP 数:${formatNumber(row.unique_ip_count || 0)}`,
|
|
`拒绝次数:${formatNumber(row.denied_count || 0)}`,
|
|
`风险等级:${row.risk_level === "high" ? "高风险" : row.risk_level === "attention" ? "关注" : "正常"}`,
|
|
...(row.risk_reasons || []).slice(0, 2),
|
|
].join("<br/>");
|
|
};
|
|
|
|
const formatGeoTooltip = (params: any) => {
|
|
const name = String(params?.name || "").trim();
|
|
return name || "暂无访问数据";
|
|
};
|
|
|
|
return {
|
|
tooltip: {
|
|
trigger: "item",
|
|
confine: true,
|
|
formatter: formatGeoTooltip,
|
|
},
|
|
geo: {
|
|
map: mapName,
|
|
roam: false,
|
|
tooltip: {
|
|
show: true,
|
|
formatter: formatGeoTooltip,
|
|
},
|
|
zoom,
|
|
label: { show: false },
|
|
itemStyle: {
|
|
areaColor: "#e6edf8",
|
|
borderColor: "#aebed2",
|
|
borderWidth: 0.8,
|
|
},
|
|
emphasis: {
|
|
label: { show: false },
|
|
itemStyle: { areaColor: "#dbeafe", borderColor: "#64748b", borderWidth: 1.3 },
|
|
},
|
|
},
|
|
series: [
|
|
{
|
|
name: seriesName,
|
|
type: "lines",
|
|
coordinateSystem: "geo",
|
|
z: 2,
|
|
tooltip: {
|
|
show: true,
|
|
formatter: formatLineTooltip,
|
|
},
|
|
effect: {
|
|
show: true,
|
|
constantSpeed: 72,
|
|
trailLength: 0,
|
|
symbol: "circle",
|
|
symbolSize: 5,
|
|
loop: true,
|
|
},
|
|
lineStyle: {
|
|
width: 1.1,
|
|
opacity: 0.42,
|
|
curveness: 0.2,
|
|
cap: "round",
|
|
join: "round",
|
|
},
|
|
data: serverCoord
|
|
? points.map((point) => {
|
|
const isElevatedRisk = point.risk_level !== "normal";
|
|
const lineColor = mapMetric.value === "risk" ? point.color : "#2563eb";
|
|
return {
|
|
fromName: point.name,
|
|
toName: server?.name || "服务器",
|
|
value: point.total_count,
|
|
riskLevel: point.risk_level,
|
|
coords: [point.coord, serverCoord],
|
|
lineStyle: {
|
|
color: lineColor,
|
|
width: isElevatedRisk ? 1.4 : 1.1,
|
|
opacity: isElevatedRisk ? 0.64 : 0.46,
|
|
cap: "round",
|
|
join: "round",
|
|
},
|
|
};
|
|
})
|
|
: [],
|
|
},
|
|
{
|
|
name: "访问来源",
|
|
type: "scatter",
|
|
coordinateSystem: "geo",
|
|
z: 3,
|
|
tooltip: {
|
|
show: true,
|
|
formatter: formatPointTooltip,
|
|
},
|
|
symbolSize: (value: number[], params: any) => {
|
|
const isElevatedRisk = params?.data?.risk_level !== "normal";
|
|
const maxSize = isElevatedRisk ? 16 : 12;
|
|
const minSize = isElevatedRisk ? 7 : 5;
|
|
return Math.min(maxSize, Math.max(minSize, Math.sqrt(value[2] || 1) * 2));
|
|
},
|
|
itemStyle: {
|
|
color: (params: any) => params.data.color,
|
|
borderColor: "rgba(255, 255, 255, 0.9)",
|
|
borderWidth: 1.5,
|
|
},
|
|
data: points.map((point) => ({
|
|
name: formatLocation(point),
|
|
value: [...point.coord, point.total_count],
|
|
total_count: point.total_count,
|
|
unique_user_count: point.unique_user_count,
|
|
unique_ip_count: point.unique_ip_count,
|
|
denied_count: point.denied_count,
|
|
risk_level: point.risk_level,
|
|
risk_reasons: point.risk_reasons,
|
|
color: point.color,
|
|
})),
|
|
},
|
|
{
|
|
name: server?.name || "服务器",
|
|
type: "effectScatter",
|
|
coordinateSystem: "geo",
|
|
z: 4,
|
|
tooltip: {
|
|
show: true,
|
|
formatter: formatPointTooltip,
|
|
},
|
|
rippleEffect: {
|
|
brushType: "stroke",
|
|
period: 4,
|
|
scale: 2,
|
|
},
|
|
symbolSize: 9,
|
|
itemStyle: {
|
|
color: "#16a34a",
|
|
borderColor: "rgba(255, 255, 255, 0.92)",
|
|
borderWidth: 1.5,
|
|
},
|
|
data: serverCoord
|
|
? [
|
|
{
|
|
name: server?.name || "服务器",
|
|
value: [...serverCoord, 1],
|
|
isServer: true,
|
|
},
|
|
]
|
|
: [],
|
|
},
|
|
],
|
|
};
|
|
};
|
|
|
|
const sourceMapOption = computed(() =>
|
|
mapView.value === "flow"
|
|
? buildFlowMapOption(flowSourceData.value)
|
|
: buildFlowMapOption(chinaFlowSourceData.value, "ctms-china", 1.08, "中国访问链路"),
|
|
);
|
|
|
|
const fullscreenSourceMapOption = computed(() =>
|
|
mapView.value === "flow"
|
|
? buildFlowMapOption(flowSourceData.value, "ctms-world", 1, "全球访问链路")
|
|
: buildFlowMapOption(chinaFlowSourceData.value, "ctms-china", 0.92, "中国访问链路"),
|
|
);
|
|
|
|
const visibleRankRows = computed(() =>
|
|
mapView.value === "china" ? chinaRankRows.value : rankedSourceRows.value,
|
|
);
|
|
|
|
const topRegions = computed(() =>
|
|
visibleRankRows.value.map((item, index) => ({
|
|
key: `${item.country}-${item.province}-${item.city}-${item.isp}`,
|
|
rank: String(index + 1).padStart(2, "0"),
|
|
name: formatLocation(item),
|
|
total_count: formatNumber(item.total_count),
|
|
risk_level: item.risk_level,
|
|
})),
|
|
);
|
|
|
|
let loadSequence = 0;
|
|
|
|
const loadData = async () => {
|
|
const requestSequence = ++loadSequence;
|
|
loading.value = true;
|
|
try {
|
|
const days = getIpLocationsDays(timeRangePreset.value);
|
|
const res = await fetchIpLocations({ days, all_time: timeRangePreset.value === "all", limit: 50 });
|
|
if (requestSequence !== loadSequence) return;
|
|
items.value = res.data.items;
|
|
summary.value = res.data.summary || null;
|
|
periodMetadata.value = res.data.period;
|
|
dataQuality.value = res.data.data_quality;
|
|
serverLocation.value = res.data.server_location;
|
|
loadError.value = "";
|
|
lastUpdatedAt.value = new Date(res.data.generated_at);
|
|
} catch {
|
|
if (requestSequence !== loadSequence) return;
|
|
loadError.value = "来源分析加载失败,图表中可能是上次成功获取的数据";
|
|
} finally {
|
|
if (requestSequence === loadSequence) loading.value = false;
|
|
}
|
|
};
|
|
|
|
onMounted(async () => {
|
|
await loadData();
|
|
await resizeSourceCharts();
|
|
});
|
|
|
|
defineExpose({ refresh: loadData, resize: resizeSourceCharts });
|
|
</script>
|
|
|
|
<style scoped>
|
|
.ip-locations {
|
|
--ip-ink: #1a2332;
|
|
--ip-muted: #64748b;
|
|
--ip-border: #e2e8f0;
|
|
--ip-card: #ffffff;
|
|
--ip-blue: #3b82f6;
|
|
--ip-cyan: #06b6d4;
|
|
--ip-violet: #8b5cf6;
|
|
--ip-danger: #ef4444;
|
|
--ip-indigo: #6366f1;
|
|
--ip-radius: 12px;
|
|
--ip-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03);
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
min-height: 0;
|
|
gap: 8px;
|
|
}
|
|
|
|
.ip-overview {
|
|
padding: 8px 12px 6px;
|
|
border: 1px solid var(--ip-border);
|
|
border-radius: var(--ip-radius);
|
|
background: var(--ip-card);
|
|
box-shadow: var(--ip-shadow);
|
|
}
|
|
|
|
.ip-hero {
|
|
padding: 0 0 7px;
|
|
border-bottom: 1px solid #edf1f6;
|
|
}
|
|
|
|
|
|
|
|
.ip-locations-toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.toolbar-left {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
|
|
.toolbar-title {
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
color: var(--ip-ink);
|
|
}
|
|
|
|
.toolbar-desc {
|
|
font-size: 12px;
|
|
color: var(--ip-muted);
|
|
}
|
|
|
|
.toolbar-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
gap: 8px;
|
|
margin-left: auto;
|
|
}
|
|
|
|
.metric-grid {
|
|
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::after {
|
|
display: none;
|
|
}
|
|
|
|
.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.indigo::before { background: linear-gradient(90deg, #6366f1, #818cf8); }
|
|
.metric-card.violet::before { background: linear-gradient(90deg, #8b5cf6, #a78bfa); }
|
|
.metric-card.danger::before { background: linear-gradient(90deg, #ef4444, #f87171); }
|
|
|
|
.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.indigo .metric-icon { background: #eef2ff; color: #6366f1; }
|
|
.metric-card.violet .metric-icon { background: #f5f3ff; color: #8b5cf6; }
|
|
.metric-card.danger .metric-icon { background: #fef2f2; color: #ef4444; }
|
|
|
|
.metric-body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.metric-label {
|
|
color: var(--ip-muted);
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.metric-value {
|
|
color: var(--ip-ink);
|
|
font-size: 18px;
|
|
font-weight: 700;
|
|
line-height: 1.1;
|
|
}
|
|
|
|
.distribution-card {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) 300px;
|
|
grid-template-rows: minmax(0, 1fr);
|
|
gap: 8px;
|
|
flex: 1 1 auto;
|
|
min-height: 0;
|
|
background: var(--ip-card);
|
|
border: 1px solid var(--ip-border);
|
|
border-radius: var(--ip-radius);
|
|
box-shadow: var(--ip-shadow);
|
|
padding: 10px;
|
|
}
|
|
|
|
.map-panel,
|
|
.rank-panel {
|
|
min-width: 0;
|
|
min-height: 0;
|
|
border: 1px solid #f1f5f9;
|
|
border-radius: 10px;
|
|
background: linear-gradient(180deg, #fafcff 0%, #ffffff 100%);
|
|
padding: 12px;
|
|
}
|
|
|
|
.map-panel,
|
|
.rank-panel,
|
|
.source-map-wrap {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.section-head {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.section-head.compact {
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.map-head-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
flex-wrap: wrap;
|
|
justify-content: flex-end;
|
|
max-width: 72%;
|
|
}
|
|
|
|
.map-fullscreen-button svg {
|
|
width: 14px;
|
|
height: 14px;
|
|
}
|
|
|
|
.flow-legend {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-start;
|
|
gap: 12px;
|
|
color: var(--ip-muted);
|
|
font-size: 12px;
|
|
line-height: 1;
|
|
}
|
|
|
|
.map-legend {
|
|
position: absolute;
|
|
left: 14px;
|
|
bottom: 14px;
|
|
z-index: 5;
|
|
padding: 9px 12px;
|
|
border: 1px solid rgba(226, 232, 240, 0.92);
|
|
border-radius: 999px;
|
|
background: rgba(255, 255, 255, 0.92);
|
|
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.08);
|
|
backdrop-filter: blur(8px);
|
|
}
|
|
|
|
.legend-item {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.legend-dot {
|
|
display: inline-block;
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 999px;
|
|
}
|
|
|
|
.legend-dot.server {
|
|
background: #16a34a;
|
|
box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.12);
|
|
}
|
|
|
|
.legend-dot.normal {
|
|
background: #2563eb;
|
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
|
|
}
|
|
|
|
.legend-dot.traffic {
|
|
background: #2563eb;
|
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
|
|
}
|
|
|
|
.legend-dot.attention {
|
|
background: #f59e0b;
|
|
box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.14);
|
|
}
|
|
|
|
.legend-dot.high {
|
|
background: #ef4444;
|
|
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.14);
|
|
}
|
|
|
|
.section-title-group h4 {
|
|
margin: 0;
|
|
color: var(--ip-ink);
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.section-title-group p {
|
|
margin: 4px 0 0;
|
|
color: var(--ip-muted);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.source-map-wrap,
|
|
.fullscreen-map-wrap {
|
|
position: relative;
|
|
min-width: 0;
|
|
min-height: 0;
|
|
flex: 1;
|
|
}
|
|
|
|
.source-map {
|
|
width: 100%;
|
|
height: 100%;
|
|
min-height: 0;
|
|
}
|
|
|
|
.rank-panel {
|
|
height: 100%;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.region-list {
|
|
flex: 1;
|
|
min-width: 0;
|
|
overflow-x: hidden;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
:global(.source-map-dialog) {
|
|
--el-dialog-padding-primary: 0;
|
|
}
|
|
|
|
:global(.source-map-overlay) {
|
|
inset: 0 !important;
|
|
width: 100vw;
|
|
height: 100vh;
|
|
}
|
|
|
|
:global(.source-map-overlay .el-overlay-dialog) {
|
|
position: absolute;
|
|
inset: 0;
|
|
align-items: stretch;
|
|
justify-content: flex-start;
|
|
width: 100%;
|
|
height: 100%;
|
|
padding: 0 !important;
|
|
overflow: hidden;
|
|
}
|
|
|
|
:global(.source-map-overlay .source-map-dialog.el-dialog.is-fullscreen) {
|
|
display: flex;
|
|
flex-direction: column;
|
|
width: 100vw;
|
|
max-width: none;
|
|
height: 100%;
|
|
margin: 0 !important;
|
|
border: 0;
|
|
border-radius: 0;
|
|
background: #f8fafc;
|
|
box-shadow: none;
|
|
backdrop-filter: none;
|
|
}
|
|
|
|
:global(.source-map-dialog .el-dialog__header) {
|
|
padding: 0;
|
|
margin: 0;
|
|
}
|
|
|
|
:global(.source-map-dialog .el-dialog__body) {
|
|
box-sizing: border-box;
|
|
flex: 1 1 auto;
|
|
height: auto;
|
|
min-height: 0;
|
|
padding: 0;
|
|
background: #f8fafc;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.fullscreen-map-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
box-sizing: border-box;
|
|
gap: 12px;
|
|
height: 48px;
|
|
min-height: 48px;
|
|
flex: 0 0 48px;
|
|
padding: 7px 12px;
|
|
border-bottom: 1px solid var(--ip-border);
|
|
background: #ffffff;
|
|
}
|
|
|
|
.fullscreen-title-group {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 8px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.fullscreen-title-group h3 {
|
|
margin: 0;
|
|
color: var(--ip-ink);
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.fullscreen-title-group span {
|
|
color: var(--ip-muted);
|
|
font-size: 12px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.fullscreen-title-group span::before {
|
|
margin-right: 8px;
|
|
color: #cbd5e1;
|
|
content: "·";
|
|
}
|
|
|
|
.fullscreen-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
gap: 8px;
|
|
flex-wrap: nowrap;
|
|
}
|
|
|
|
.fullscreen-source-map {
|
|
width: 100%;
|
|
height: 100%;
|
|
min-height: 0;
|
|
}
|
|
|
|
.fullscreen-map-wrap {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
.region-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
|
|
.region-row {
|
|
box-sizing: border-box;
|
|
display: grid;
|
|
grid-template-columns: 36px minmax(0, 1fr) minmax(72px, max-content);
|
|
gap: 10px;
|
|
align-items: center;
|
|
min-width: 0;
|
|
width: 100%;
|
|
max-width: 100%;
|
|
padding: 8px;
|
|
border-radius: 8px;
|
|
background: #fff;
|
|
border: 1px solid #f1f5f9;
|
|
color: var(--ip-ink);
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.region-row:hover {
|
|
background: #f8fafc;
|
|
border-color: var(--ip-border);
|
|
}
|
|
|
|
.rank {
|
|
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;
|
|
min-width: 0;
|
|
}
|
|
|
|
.rank.top {
|
|
background: linear-gradient(135deg, #3b82f6, #6366f1);
|
|
color: #fff;
|
|
}
|
|
|
|
.region-name {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 7px;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.region-risk-dot {
|
|
width: 7px;
|
|
height: 7px;
|
|
flex: 0 0 7px;
|
|
border-radius: 999px;
|
|
background: #2563eb;
|
|
}
|
|
|
|
.region-risk-dot.attention {
|
|
background: #f59e0b;
|
|
}
|
|
|
|
.region-risk-dot.high {
|
|
background: #ef4444;
|
|
}
|
|
|
|
.region-value {
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
color: var(--ip-blue);
|
|
font-size: 15px;
|
|
font-weight: 700;
|
|
text-align: right;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
@media (max-width: 1100px) {
|
|
.metric-grid {
|
|
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;
|
|
}
|
|
|
|
.distribution-card {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.map-panel,
|
|
.rank-panel {
|
|
min-height: 0;
|
|
}
|
|
|
|
.rank-panel {
|
|
max-height: none;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 720px) {
|
|
.ip-locations-toolbar {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
}
|
|
|
|
.toolbar-actions {
|
|
justify-content: flex-end;
|
|
width: 100%;
|
|
margin-left: 0;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.metric-grid {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
|
|
.source-map {
|
|
height: 100%;
|
|
min-height: 0;
|
|
}
|
|
|
|
.map-head-actions {
|
|
justify-content: flex-start;
|
|
max-width: none;
|
|
}
|
|
|
|
:global(.source-map-dialog .el-dialog__body) {
|
|
padding: 0;
|
|
}
|
|
|
|
.fullscreen-map-header {
|
|
align-items: flex-start;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
height: 78px;
|
|
min-height: 78px;
|
|
flex-basis: 78px;
|
|
padding: 8px 10px;
|
|
}
|
|
|
|
.fullscreen-actions {
|
|
justify-content: flex-start;
|
|
}
|
|
|
|
.fullscreen-source-map {
|
|
height: 100%;
|
|
min-height: 0;
|
|
}
|
|
|
|
.map-legend {
|
|
left: 10px;
|
|
right: 10px;
|
|
bottom: 10px;
|
|
justify-content: center;
|
|
border-radius: 12px;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 520px) {
|
|
.metric-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.metric-card {
|
|
border-top: 1px solid #edf1f6;
|
|
border-left: 0;
|
|
}
|
|
|
|
.metric-card:first-child {
|
|
border-top: 0;
|
|
}
|
|
}
|
|
</style>
|