完善权限监控与安全中心
This commit is contained in:
@@ -4,7 +4,6 @@
|
||||
<div class="ip-locations-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span class="toolbar-title">访问来源分析</span>
|
||||
<span class="toolbar-desc">查看访问来源的地理分布</span>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-radio-group v-model="days" size="small" @change="loadData">
|
||||
@@ -13,7 +12,7 @@
|
||||
<el-radio-button :value="30">30天</el-radio-button>
|
||||
<el-radio-button :value="90">90天</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-button :loading="loading" size="small" @click="loadData">
|
||||
<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>
|
||||
@@ -33,23 +32,44 @@
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section v-loading="loading" class="distribution-card">
|
||||
<section class="distribution-card">
|
||||
<div class="map-panel">
|
||||
<div class="section-head">
|
||||
<div class="section-title-group">
|
||||
<h4>来源分布</h4>
|
||||
<p>访问来源地理分布热力图</p>
|
||||
</div>
|
||||
<el-tag type="info" effect="plain" round size="small">{{ currentPeriodLabel }}</el-tag>
|
||||
<div class="map-head-actions">
|
||||
<el-segmented v-model="mapView" :options="mapViewOptions" size="small" />
|
||||
<el-tag type="info" effect="plain" round size="small">{{ currentPeriodLabel }}</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">
|
||||
<v-chart
|
||||
ref="sourceChartRef"
|
||||
:key="mapView"
|
||||
:option="sourceMapOption"
|
||||
:init-options="chartInitOptions"
|
||||
:update-options="chartUpdateOptions"
|
||||
autoresize
|
||||
class="source-map"
|
||||
/>
|
||||
<div class="flow-legend map-legend" aria-label="访问链路图例">
|
||||
<span class="legend-item"><i class="legend-dot server"></i>服务器</span>
|
||||
<span class="legend-item"><i class="legend-dot normal"></i>正常访问点</span>
|
||||
<span class="legend-item"><i class="legend-dot abnormal"></i>异常访问点</span>
|
||||
</div>
|
||||
</div>
|
||||
<v-chart :option="chinaMapOption" autoresize class="china-map" />
|
||||
</div>
|
||||
|
||||
<aside class="rank-panel">
|
||||
<div class="section-head compact">
|
||||
<div class="section-title-group">
|
||||
<h4>热点来源地</h4>
|
||||
<p>按访问次数排序</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="topRegions.length" class="region-list">
|
||||
@@ -63,23 +83,65 @@
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<el-dialog
|
||||
v-model="fullscreenVisible"
|
||||
class="source-map-dialog"
|
||||
fullscreen
|
||||
destroy-on-close
|
||||
:show-close="false"
|
||||
:close-on-click-modal="false"
|
||||
append-to-body
|
||||
@opened="resizeFullscreenChart"
|
||||
>
|
||||
<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="mapView" :options="mapViewOptions" size="small" />
|
||||
<el-button size="small" @click="fullscreenVisible = false">退出全屏</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="fullscreen-map-wrap">
|
||||
<v-chart
|
||||
ref="fullscreenChartRef"
|
||||
:key="fullscreenMapKey"
|
||||
:option="sourceMapOption"
|
||||
:init-options="fullscreenChartInitOptions"
|
||||
:update-options="chartUpdateOptions"
|
||||
autoresize
|
||||
class="fullscreen-source-map"
|
||||
/>
|
||||
<div class="flow-legend map-legend" aria-label="访问链路图例">
|
||||
<span class="legend-item"><i class="legend-dot server"></i>服务器</span>
|
||||
<span class="legend-item"><i class="legend-dot normal"></i>正常访问点</span>
|
||||
<span class="legend-item"><i class="legend-dot abnormal"></i>异常访问点</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, ref } from "vue";
|
||||
import { computed, h, nextTick, onMounted, ref } from "vue";
|
||||
import VChart from "vue-echarts";
|
||||
import { registerMap, use } from "echarts/core";
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
import { MapChart } from "echarts/charts";
|
||||
import { TooltipComponent, VisualMapComponent } from "echarts/components";
|
||||
import { EffectScatterChart, LinesChart } from "echarts/charts";
|
||||
import { GeoComponent, TooltipComponent } from "echarts/components";
|
||||
import chinaMapGeoJson from "../assets/china.json";
|
||||
import worldMapGeoJson from "../assets/world.json";
|
||||
import { fetchIpLocations } from "../api/projectPermissions";
|
||||
import type { IpLocationsResponse, IpLocationStatItem } from "../types/api";
|
||||
import { normalizeProvinceName } from "./chinaProvinceMap";
|
||||
import { resolveSourceCoordinate } from "./worldMapSource";
|
||||
|
||||
use([CanvasRenderer, MapChart, TooltipComponent, VisualMapComponent]);
|
||||
use([CanvasRenderer, LinesChart, EffectScatterChart, GeoComponent, TooltipComponent]);
|
||||
registerMap("ctms-china", chinaMapGeoJson as any);
|
||||
registerMap("ctms-world", worldMapGeoJson as any);
|
||||
|
||||
const IconGlobe = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("circle", { cx: "12", cy: "12", r: "10" }),
|
||||
@@ -103,9 +165,22 @@ const IconShield = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke:
|
||||
]);
|
||||
|
||||
const loading = ref(false);
|
||||
const days = ref(7);
|
||||
const days = ref(90);
|
||||
const mapView = ref<"china" | "flow">("china");
|
||||
const items = ref<IpLocationStatItem[]>([]);
|
||||
const summary = ref<IpLocationsResponse["summary"] | null>(null);
|
||||
const getDevicePixelRatio = () => (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1);
|
||||
const chartInitOptions = { devicePixelRatio: Math.min(getDevicePixelRatio(), 2) };
|
||||
const fullscreenChartInitOptions = { devicePixelRatio: Math.min(getDevicePixelRatio() * 1.5, 3) };
|
||||
const chartUpdateOptions = { notMerge: true };
|
||||
const fullscreenVisible = ref(false);
|
||||
const sourceChartRef = ref<{ resize?: () => void } | null>(null);
|
||||
const fullscreenChartRef = ref<{ resize?: () => void } | null>(null);
|
||||
|
||||
const serverLocation = {
|
||||
name: "服务器所在地",
|
||||
coord: [121.4737, 31.2304] as [number, number],
|
||||
};
|
||||
|
||||
const periodLabels: Record<number, string> = {
|
||||
1: "近 24 小时",
|
||||
@@ -115,11 +190,31 @@ const periodLabels: Record<number, string> = {
|
||||
};
|
||||
|
||||
const currentPeriodLabel = computed(() => periodLabels[days.value] || `近 ${days.value} 天`);
|
||||
const mapViewOptions = [
|
||||
{ label: "中国", value: "china" },
|
||||
{ label: "全球", value: "flow" },
|
||||
];
|
||||
const fullscreenMapKey = computed(() => `fullscreen-${mapView.value}-${fullscreenVisible.value ? "open" : "closed"}`);
|
||||
|
||||
const openFullscreenPreview = () => {
|
||||
fullscreenVisible.value = true;
|
||||
};
|
||||
|
||||
const resizeFullscreenChart = async () => {
|
||||
await nextTick();
|
||||
fullscreenChartRef.value?.resize?.();
|
||||
};
|
||||
|
||||
const resizeSourceCharts = async () => {
|
||||
await nextTick();
|
||||
sourceChartRef.value?.resize?.();
|
||||
fullscreenChartRef.value?.resize?.();
|
||||
};
|
||||
|
||||
const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
|
||||
|
||||
const formatLocation = (row: IpLocationStatItem) => {
|
||||
const parts = [row.province, row.city].filter(Boolean);
|
||||
const parts = [row.country && row.country !== "中国" ? row.country : "", row.province, row.city, row.isp].filter(Boolean);
|
||||
return parts.length ? parts.join(" / ") : row.location;
|
||||
};
|
||||
|
||||
@@ -166,95 +261,203 @@ const metricCards = computed(() => [
|
||||
},
|
||||
]);
|
||||
|
||||
const provinceData = computed(() => {
|
||||
const provinceMap = new Map<string, IpLocationStatItem & { name: string }>();
|
||||
items.value.forEach((item) => {
|
||||
const name = normalizeProvinceName(item.province || item.city);
|
||||
if (!name || item.country !== "中国") return;
|
||||
const current = provinceMap.get(name);
|
||||
if (!current) {
|
||||
provinceMap.set(name, { ...item, name });
|
||||
return;
|
||||
}
|
||||
current.total_count += item.total_count;
|
||||
current.allowed_count += item.allowed_count;
|
||||
current.denied_count += item.denied_count;
|
||||
current.unique_ip_count += item.unique_ip_count;
|
||||
current.unique_user_count += item.unique_user_count || 0;
|
||||
});
|
||||
return Array.from(provinceMap.values());
|
||||
});
|
||||
const rankedSourceRows = computed(() => [...items.value].sort((a, b) => b.total_count - a.total_count).slice(0, 10));
|
||||
|
||||
const maxValue = computed(() => {
|
||||
const values = provinceData.value.map((item) => item.total_count);
|
||||
return values.length ? Math.max(...values) : 100;
|
||||
});
|
||||
const isChinaSource = (item: IpLocationStatItem) => item.location === "局域网" || item.country === "中国";
|
||||
|
||||
const chinaMapOption = computed(() => ({
|
||||
tooltip: {
|
||||
trigger: "item",
|
||||
formatter: (params: any) => {
|
||||
const data = params.data;
|
||||
if (!data) return `${params.name}<br/>暂无访问数据`;
|
||||
return [
|
||||
`${params.name}`,
|
||||
`访问次数:${formatNumber(data.value)}`,
|
||||
`访问用户数:${formatNumber(data.unique_user_count || 0)}`,
|
||||
`来源 IP 数:${formatNumber(data.unique_ip_count || 0)}`,
|
||||
`拒绝次数:${formatNumber(data.denied_count || 0)}`,
|
||||
].join("<br/>");
|
||||
const chinaFlowSourceData = computed(() => rankedSourceRows.value.filter((item) => isChinaSource(item) && resolveSourceCoordinate(item)));
|
||||
|
||||
const flowSourceData = computed(() => rankedSourceRows.value.filter((item) => resolveSourceCoordinate(item)));
|
||||
|
||||
const buildFlowMapOption = (data: IpLocationStatItem[], mapName = "ctms-world", zoom = 1.2, seriesName = "全球访问链路") => {
|
||||
const points = data
|
||||
.map((item) => ({
|
||||
...item,
|
||||
name: formatLocation(item),
|
||||
coord: resolveSourceCoordinate(item),
|
||||
abnormal: Boolean(item.country && item.country !== "中国"),
|
||||
}))
|
||||
.filter((item): item is IpLocationStatItem & { name: string; coord: [number, number]; abnormal: boolean } => Boolean(item.coord));
|
||||
|
||||
const formatLineTooltip = (params: any) => {
|
||||
const row = params.data;
|
||||
if (!row) return "";
|
||||
return [
|
||||
`${row.fromName} → ${row.toName}`,
|
||||
`访问次数:${formatNumber(row.value || 0)}`,
|
||||
row.abnormal ? "事件类型:异常 IP" : "事件类型:正常访问",
|
||||
].join("<br/>");
|
||||
};
|
||||
|
||||
const formatPointTooltip = (params: any) => {
|
||||
const row = params.data;
|
||||
if (!row) return "";
|
||||
if (row.isServer) return "中国 / 上海";
|
||||
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.abnormal ? "事件类型:异常 IP" : "事件类型:正常访问",
|
||||
].join("<br/>");
|
||||
};
|
||||
|
||||
const formatGeoTooltip = (params: any) => {
|
||||
const name = String(params?.name || "").trim();
|
||||
return name || "暂无访问数据";
|
||||
};
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: "item",
|
||||
confine: true,
|
||||
formatter: formatGeoTooltip,
|
||||
},
|
||||
},
|
||||
visualMap: {
|
||||
min: 0,
|
||||
max: maxValue.value,
|
||||
left: "left",
|
||||
bottom: "20",
|
||||
text: ["高", "低"],
|
||||
inRange: {
|
||||
color: ["#edf3ff", "#a5c4fd", "#6399f7", "#3b82f6", "#1d4ed8"],
|
||||
},
|
||||
show: true,
|
||||
calculable: false,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: "访问来源分布",
|
||||
type: "map",
|
||||
map: "ctms-china",
|
||||
geo: {
|
||||
map: mapName,
|
||||
roam: false,
|
||||
zoom: 1.08,
|
||||
tooltip: {
|
||||
show: true,
|
||||
formatter: formatGeoTooltip,
|
||||
},
|
||||
zoom,
|
||||
label: { show: false },
|
||||
itemStyle: {
|
||||
areaColor: "#edf3ff",
|
||||
borderColor: "#ffffff",
|
||||
areaColor: "#e6edf8",
|
||||
borderColor: "#aebed2",
|
||||
borderWidth: 1,
|
||||
shadowColor: "rgba(37, 99, 235, 0.08)",
|
||||
shadowBlur: 10,
|
||||
shadowOffsetY: 2,
|
||||
},
|
||||
emphasis: {
|
||||
label: { show: true, color: "#0f172a", fontSize: 12, fontWeight: 700 },
|
||||
itemStyle: { areaColor: "#ffb86b", shadowBlur: 12, shadowColor: "rgba(245, 158, 11, 0.28)" },
|
||||
label: { show: false },
|
||||
itemStyle: { areaColor: "#dbeafe", borderColor: "#64748b", borderWidth: 1.3 },
|
||||
},
|
||||
data: provinceData.value.map((item) => ({
|
||||
name: item.name,
|
||||
value: item.total_count,
|
||||
unique_user_count: item.unique_user_count,
|
||||
unique_ip_count: item.unique_ip_count,
|
||||
denied_count: item.denied_count,
|
||||
})),
|
||||
},
|
||||
],
|
||||
}));
|
||||
series: [
|
||||
{
|
||||
name: seriesName,
|
||||
type: "lines",
|
||||
coordinateSystem: "geo",
|
||||
zlevel: 2,
|
||||
tooltip: {
|
||||
show: true,
|
||||
formatter: formatLineTooltip,
|
||||
},
|
||||
effect: {
|
||||
show: true,
|
||||
period: 4,
|
||||
trailLength: 0.18,
|
||||
symbol: "arrow",
|
||||
symbolSize: 6,
|
||||
},
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
opacity: 0.28,
|
||||
curveness: 0.2,
|
||||
},
|
||||
data: points
|
||||
.filter((point) => point.name !== serverLocation.name)
|
||||
.map((point) => {
|
||||
const lineWidth = point.abnormal ? 1.1 : 0.8;
|
||||
return {
|
||||
fromName: point.name,
|
||||
toName: serverLocation.name,
|
||||
value: point.total_count,
|
||||
abnormal: point.abnormal,
|
||||
coords: [point.coord, serverLocation.coord],
|
||||
lineStyle: {
|
||||
color: point.abnormal ? "#f97316" : "#2563eb",
|
||||
width: lineWidth,
|
||||
opacity: point.abnormal ? 0.58 : 0.38,
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "访问来源",
|
||||
type: "effectScatter",
|
||||
coordinateSystem: "geo",
|
||||
zlevel: 3,
|
||||
tooltip: {
|
||||
show: true,
|
||||
formatter: formatPointTooltip,
|
||||
},
|
||||
rippleEffect: {
|
||||
brushType: "stroke",
|
||||
scale: 1.7,
|
||||
},
|
||||
symbolSize: (value: number[], params: any) => {
|
||||
const isDomestic = !params?.data?.abnormal;
|
||||
const maxSize = isDomestic ? 12 : 16;
|
||||
const minSize = isDomestic ? 5 : 7;
|
||||
return Math.min(maxSize, Math.max(minSize, Math.sqrt(value[2] || 1) * 2));
|
||||
},
|
||||
itemStyle: {
|
||||
color: (params: any) => (params.data.abnormal ? "#f97316" : "#2563eb"),
|
||||
shadowBlur: 6,
|
||||
shadowColor: "rgba(37, 99, 235, 0.2)",
|
||||
},
|
||||
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,
|
||||
abnormal: point.abnormal,
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: serverLocation.name,
|
||||
type: "effectScatter",
|
||||
coordinateSystem: "geo",
|
||||
zlevel: 4,
|
||||
tooltip: {
|
||||
show: true,
|
||||
formatter: formatPointTooltip,
|
||||
},
|
||||
rippleEffect: {
|
||||
brushType: "fill",
|
||||
scale: 1.5,
|
||||
},
|
||||
symbolSize: 9,
|
||||
itemStyle: {
|
||||
color: "#16a34a",
|
||||
shadowBlur: 6,
|
||||
shadowColor: "rgba(22, 163, 74, 0.24)",
|
||||
},
|
||||
data: [
|
||||
{
|
||||
name: serverLocation.name,
|
||||
value: [...serverLocation.coord, 1],
|
||||
isServer: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const sourceMapOption = computed(() =>
|
||||
mapView.value === "flow"
|
||||
? buildFlowMapOption(flowSourceData.value)
|
||||
: buildFlowMapOption(chinaFlowSourceData.value, "ctms-china", 1.08, "中国访问链路"),
|
||||
);
|
||||
|
||||
const visibleRankRows = computed(() =>
|
||||
mapView.value === "china" ? chinaFlowSourceData.value : mapView.value === "flow" ? flowSourceData.value : rankedSourceRows.value,
|
||||
);
|
||||
|
||||
const topRegions = computed(() =>
|
||||
[...items.value]
|
||||
.sort((a, b) => b.total_count - a.total_count)
|
||||
.slice(0, 6)
|
||||
.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),
|
||||
})),
|
||||
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),
|
||||
})),
|
||||
);
|
||||
|
||||
const loadData = async () => {
|
||||
@@ -271,9 +474,12 @@ const loadData = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(loadData);
|
||||
onMounted(async () => {
|
||||
await loadData();
|
||||
await resizeSourceCharts();
|
||||
});
|
||||
|
||||
defineExpose({ refresh: loadData });
|
||||
defineExpose({ refresh: loadData, resize: resizeSourceCharts });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -291,6 +497,8 @@ defineExpose({ refresh: loadData });
|
||||
--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: 14px;
|
||||
}
|
||||
|
||||
@@ -305,7 +513,7 @@ defineExpose({ refresh: loadData });
|
||||
.ip-locations-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -330,7 +538,9 @@ defineExpose({ refresh: loadData });
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
@@ -357,8 +567,8 @@ defineExpose({ refresh: loadData });
|
||||
.metric-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -46px;
|
||||
bottom: -52px;
|
||||
right: -34px;
|
||||
bottom: -42px;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 18px;
|
||||
@@ -432,7 +642,10 @@ defineExpose({ refresh: loadData });
|
||||
.distribution-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
background: var(--ip-card);
|
||||
border: 1px solid var(--ip-border);
|
||||
border-radius: var(--ip-radius);
|
||||
@@ -443,12 +656,20 @@ defineExpose({ refresh: loadData });
|
||||
.map-panel,
|
||||
.rank-panel {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: 1px solid #f1f5f9;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, #fafcff 0%, #ffffff 100%);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.map-panel,
|
||||
.rank-panel,
|
||||
.source-map-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -461,6 +682,72 @@ defineExpose({ refresh: loadData });
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.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.abnormal {
|
||||
background: #f97316;
|
||||
box-shadow: 0 0 0 3px rgba(249, 115, 22, 0.14);
|
||||
}
|
||||
|
||||
.section-title-group h4 {
|
||||
margin: 0;
|
||||
color: var(--ip-ink);
|
||||
@@ -474,9 +761,89 @@ defineExpose({ refresh: loadData });
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.china-map {
|
||||
.source-map-wrap,
|
||||
.fullscreen-map-wrap {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.source-map {
|
||||
width: 100%;
|
||||
height: 430px;
|
||||
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-dialog .el-dialog__header) {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:global(.source-map-dialog .el-dialog__body) {
|
||||
height: calc(100vh - 74px);
|
||||
padding: 0 22px 22px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.fullscreen-map-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
min-height: 74px;
|
||||
padding: 16px 22px;
|
||||
border-bottom: 1px solid var(--ip-border);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.fullscreen-title-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fullscreen-title-group h3 {
|
||||
margin: 0;
|
||||
color: var(--ip-ink);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fullscreen-title-group span {
|
||||
color: var(--ip-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.fullscreen-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fullscreen-source-map {
|
||||
width: 100%;
|
||||
height: calc(100vh - 96px);
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
.region-list {
|
||||
@@ -486,12 +853,15 @@ defineExpose({ refresh: loadData });
|
||||
}
|
||||
|
||||
.region-row {
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(0, 1fr) auto;
|
||||
grid-template-columns: 36px minmax(0, 1fr) minmax(72px, max-content);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
max-width: 100%;
|
||||
padding: 12px 10px;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
border: 1px solid #f1f5f9;
|
||||
@@ -515,6 +885,7 @@ defineExpose({ refresh: loadData });
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rank.top {
|
||||
@@ -523,6 +894,7 @@ defineExpose({ refresh: loadData });
|
||||
}
|
||||
|
||||
.region-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
@@ -531,9 +903,14 @@ defineExpose({ refresh: loadData });
|
||||
}
|
||||
|
||||
.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) {
|
||||
@@ -544,20 +921,72 @@ defineExpose({ refresh: loadData });
|
||||
.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: flex-start;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.china-map {
|
||||
height: 320px;
|
||||
.source-map {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.map-head-actions {
|
||||
justify-content: flex-start;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
:global(.source-map-dialog .el-dialog__body) {
|
||||
height: calc(100vh - 126px);
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
|
||||
.fullscreen-map-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 126px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.fullscreen-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.fullscreen-source-map {
|
||||
height: calc(100vh - 144px);
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.map-legend {
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user