权限监控与管理界面全面美化
重新设计权限系统所有 UI 组件的视觉风格,统一配色、圆角、阴影和交互动效: - 实时概览:统计卡片加图标和渐变色条,健康评分改为环形进度,告警改为卡片式 - 趋势分析:图表卡片加彩色图标标识,ECharts 配色升级为渐变面积填充 - 访问日志:指标卡片带图标,日志审计改为卡片式入口,IP排行前三高亮 - IP属地:工具栏重设计,排行列表前三渐变高亮,指标卡片统一新风格 - 系统级权限:从 el-table 改为自定义卡片列表,模块块独立圆角卡片 - 项目权限配置:空状态引导优化,成员表格加头像,工具栏加背景容器 - 角色概览卡片:加进度条可视化,hover 微动效 - 接口权限矩阵:工具栏分离布局,表格圆角包裹 - 角色管理抽屉:侧边栏选中态渐变,操作行 hover 高亮 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,546 @@
|
||||
<template>
|
||||
<div class="ip-locations">
|
||||
<section class="ip-hero">
|
||||
<div class="ip-locations-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span class="toolbar-title">IP 属地分析</span>
|
||||
<span class="toolbar-desc">查看访问来源的地理分布</span>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-radio-group v-model="days" size="small" @change="loadData">
|
||||
<el-radio-button :value="1">24小时</el-radio-button>
|
||||
<el-radio-button :value="7">7天</el-radio-button>
|
||||
<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">
|
||||
<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">
|
||||
<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 v-loading="loading" 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>
|
||||
<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">
|
||||
<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">{{ region.name }}</span>
|
||||
<span class="region-value">{{ region.total_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无属地数据" :image-size="72" />
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, 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 } from "echarts/components";
|
||||
import chinaMapGeoJson from "../assets/china.json";
|
||||
import { fetchIpLocations } from "../api/projectPermissions";
|
||||
import type { IpLocationsResponse, IpLocationStatItem } from "../types/api";
|
||||
import { normalizeProvinceName } from "./chinaProvinceMap";
|
||||
|
||||
use([CanvasRenderer, MapChart, TooltipComponent]);
|
||||
registerMap("ctms-china", chinaMapGeoJson 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" }),
|
||||
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);
|
||||
const days = ref(7);
|
||||
const items = ref<IpLocationStatItem[]>([]);
|
||||
const summary = ref<IpLocationsResponse["summary"] | null>(null);
|
||||
|
||||
const periodLabels: Record<number, string> = {
|
||||
1: "近 24 小时",
|
||||
7: "近 7 天",
|
||||
30: "近 30 天",
|
||||
90: "近 90 天",
|
||||
};
|
||||
|
||||
const currentPeriodLabel = computed(() => periodLabels[days.value] || `近 ${days.value} 天`);
|
||||
|
||||
const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
|
||||
|
||||
const formatLocation = (row: IpLocationStatItem) => {
|
||||
const parts = [row.province, row.city].filter(Boolean);
|
||||
return parts.length ? parts.join(" / ") : row.location;
|
||||
};
|
||||
|
||||
const fallbackSummary = computed<IpLocationsResponse["summary"]>(() => ({
|
||||
total_count: items.value.reduce((sum, row) => sum + row.total_count, 0),
|
||||
allowed_count: items.value.reduce((sum, row) => sum + row.allowed_count, 0),
|
||||
denied_count: items.value.reduce((sum, row) => sum + row.denied_count, 0),
|
||||
unique_ip_count: items.value.reduce((sum, row) => sum + row.unique_ip_count, 0),
|
||||
unique_user_count: items.value.reduce((sum, row) => sum + (row.unique_user_count || 0), 0),
|
||||
}));
|
||||
|
||||
const activeSummary = computed(() => summary.value || fallbackSummary.value);
|
||||
|
||||
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 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 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/>");
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: "用户分布图",
|
||||
type: "map",
|
||||
map: "ctms-china",
|
||||
roam: false,
|
||||
zoom: 1.08,
|
||||
label: { show: false },
|
||||
itemStyle: {
|
||||
areaColor: "#edf3ff",
|
||||
borderColor: "#ffffff",
|
||||
borderWidth: 1,
|
||||
},
|
||||
emphasis: {
|
||||
label: { show: true, color: "#0f172a", fontSize: 12, fontWeight: 700 },
|
||||
itemStyle: { areaColor: "#ffb86b", shadowBlur: 12, shadowColor: "rgba(245, 158, 11, 0.28)" },
|
||||
},
|
||||
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,
|
||||
})),
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
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),
|
||||
})),
|
||||
);
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await fetchIpLocations({ days: days.value, limit: 50 });
|
||||
items.value = res.data.items;
|
||||
summary.value = res.data.summary || null;
|
||||
} catch {
|
||||
items.value = [];
|
||||
summary.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(loadData);
|
||||
|
||||
defineExpose({ refresh: loadData });
|
||||
</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: 16px;
|
||||
--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;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.ip-hero {
|
||||
background: var(--ip-card);
|
||||
border: 1px solid var(--ip-border);
|
||||
border-radius: var(--ip-radius);
|
||||
box-shadow: var(--ip-shadow);
|
||||
padding: 14px 18px;
|
||||
}
|
||||
|
||||
.ip-locations-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
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;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-height: 64px;
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--ip-radius);
|
||||
background: #fff;
|
||||
border: 1px solid var(--ip-border);
|
||||
box-shadow: var(--ip-shadow);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.metric-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -46px;
|
||||
bottom: -52px;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 18px;
|
||||
background: rgba(59, 130, 246, 0.06);
|
||||
transform: rotate(18deg);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.metric-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.metric-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
border-radius: var(--ip-radius) var(--ip-radius) 0 0;
|
||||
}
|
||||
|
||||
.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: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.metric-icon svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.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: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
color: var(--ip-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
color: var(--ip-ink);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.distribution-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: 14px;
|
||||
background: var(--ip-card);
|
||||
border: 1px solid var(--ip-border);
|
||||
border-radius: var(--ip-radius);
|
||||
box-shadow: var(--ip-shadow);
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.map-panel,
|
||||
.rank-panel {
|
||||
min-width: 0;
|
||||
border: 1px solid #f1f5f9;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, #fafcff 0%, #ffffff 100%);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-head.compact {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title-group h4 {
|
||||
margin: 0;
|
||||
color: var(--ip-ink);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-title-group p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--ip-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.china-map {
|
||||
width: 100%;
|
||||
height: 430px;
|
||||
}
|
||||
|
||||
.region-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.region-row {
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
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;
|
||||
}
|
||||
|
||||
.rank.top {
|
||||
background: linear-gradient(135deg, #3b82f6, #6366f1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.region-name {
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.region-value {
|
||||
color: var(--ip-blue);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.metric-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.distribution-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.ip-locations-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.china-map {
|
||||
height: 320px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user