权限监控与管理界面全面美化
重新设计权限系统所有 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,324 @@
|
||||
<template>
|
||||
<div class="trend-charts">
|
||||
<div class="trend-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span class="toolbar-title">数据趋势</span>
|
||||
<span class="toolbar-desc">查看权限系统各项指标的变化趋势</span>
|
||||
</div>
|
||||
<el-radio-group v-model="period" size="small" @change="loadData">
|
||||
<el-radio-button value="24h">24小时</el-radio-button>
|
||||
<el-radio-button value="7d">7天</el-radio-button>
|
||||
<el-radio-button value="30d">30天</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="charts-grid">
|
||||
<div class="chart-card">
|
||||
<div class="chart-header">
|
||||
<div class="chart-icon blue">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
||||
</div>
|
||||
<h4>检查量趋势</h4>
|
||||
</div>
|
||||
<v-chart :option="checksChartOption" autoresize class="chart" />
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="chart-header">
|
||||
<div class="chart-icon cyan">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
</div>
|
||||
<h4>响应时间趋势</h4>
|
||||
</div>
|
||||
<v-chart :option="responseTimeOption" autoresize class="chart" />
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="chart-header">
|
||||
<div class="chart-icon green">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||
</div>
|
||||
<h4>缓存命中率</h4>
|
||||
</div>
|
||||
<v-chart :option="cacheHitOption" autoresize class="chart" />
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="chart-header">
|
||||
<div class="chart-icon danger">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>
|
||||
</div>
|
||||
<h4>拒绝率趋势</h4>
|
||||
</div>
|
||||
<v-chart :option="denyRateOption" autoresize class="chart" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import VChart from "vue-echarts";
|
||||
import { use } from "echarts/core";
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
import { LineChart, BarChart } from "echarts/charts";
|
||||
import {
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
} from "echarts/components";
|
||||
import { fetchPermissionTrends } from "@/api/projectPermissions";
|
||||
import type { TrendDataPoint } from "@/types/api";
|
||||
|
||||
use([CanvasRenderer, LineChart, BarChart, TitleComponent, TooltipComponent, GridComponent, LegendComponent]);
|
||||
|
||||
const period = ref<"24h" | "7d" | "30d">("24h");
|
||||
const loading = ref(false);
|
||||
const dataPoints = ref<TrendDataPoint[]>([]);
|
||||
|
||||
const formatTime = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
if (period.value === "24h") return `${d.getHours()}:00`;
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`;
|
||||
};
|
||||
|
||||
const xLabels = computed(() => dataPoints.value.map((p: TrendDataPoint) => formatTime(p.bucket_time)));
|
||||
|
||||
const baseGridOption = {
|
||||
grid: { left: 50, right: 20, top: 20, bottom: 25 },
|
||||
tooltip: { trigger: "axis" as const },
|
||||
};
|
||||
|
||||
const checksChartOption = computed(() => ({
|
||||
grid: { left: 50, right: 20, top: 20, bottom: 50 },
|
||||
tooltip: { trigger: "axis" as const },
|
||||
legend: { data: ["允许", "拒绝"], bottom: 0, itemGap: 20 },
|
||||
xAxis: { type: "category", data: xLabels.value, axisLine: { lineStyle: { color: "#e2e8f0" } }, axisLabel: { color: "#64748b" } },
|
||||
yAxis: { type: "value", minInterval: 1, splitLine: { lineStyle: { color: "#f1f5f9" } }, axisLabel: { color: "#64748b" } },
|
||||
series: [
|
||||
{
|
||||
name: "允许",
|
||||
type: "line",
|
||||
smooth: true,
|
||||
symbol: "circle",
|
||||
symbolSize: 6,
|
||||
data: dataPoints.value.map((p: TrendDataPoint) => p.allowed_checks),
|
||||
itemStyle: { color: "#10b981" },
|
||||
lineStyle: { width: 2.5 },
|
||||
areaStyle: { color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "rgba(16,185,129,0.2)" }, { offset: 1, color: "rgba(16,185,129,0)" }] } },
|
||||
},
|
||||
{
|
||||
name: "拒绝",
|
||||
type: "line",
|
||||
smooth: true,
|
||||
symbol: "circle",
|
||||
symbolSize: 6,
|
||||
data: dataPoints.value.map((p: TrendDataPoint) => p.denied_checks),
|
||||
itemStyle: { color: "#ef4444" },
|
||||
lineStyle: { width: 2.5 },
|
||||
areaStyle: { color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "rgba(239,68,68,0.15)" }, { offset: 1, color: "rgba(239,68,68,0)" }] } },
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const responseTimeOption = computed(() => ({
|
||||
grid: { left: 50, right: 20, top: 20, bottom: 50 },
|
||||
tooltip: { trigger: "axis" as const },
|
||||
legend: { data: ["平均响应时间", "最大响应时间"], bottom: 0, itemGap: 20 },
|
||||
xAxis: { type: "category", data: xLabels.value, axisLine: { lineStyle: { color: "#e2e8f0" } }, axisLabel: { color: "#64748b" } },
|
||||
yAxis: { type: "value", name: "ms", splitLine: { lineStyle: { color: "#f1f5f9" } }, axisLabel: { color: "#64748b" } },
|
||||
series: [
|
||||
{
|
||||
name: "平均响应时间",
|
||||
type: "line",
|
||||
smooth: true,
|
||||
symbol: "circle",
|
||||
symbolSize: 6,
|
||||
data: dataPoints.value.map((p: TrendDataPoint) => p.avg_elapsed_ms),
|
||||
itemStyle: { color: "#3b82f6" },
|
||||
lineStyle: { width: 2.5 },
|
||||
areaStyle: { color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "rgba(59,130,246,0.15)" }, { offset: 1, color: "rgba(59,130,246,0)" }] } },
|
||||
},
|
||||
{
|
||||
name: "最大响应时间",
|
||||
type: "line",
|
||||
smooth: true,
|
||||
symbol: "diamond",
|
||||
symbolSize: 6,
|
||||
data: dataPoints.value.map((p: TrendDataPoint) => p.max_elapsed_ms),
|
||||
itemStyle: { color: "#f59e0b" },
|
||||
lineStyle: { width: 2, type: "dashed" },
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const cacheHitOption = computed(() => ({
|
||||
grid: { left: 50, right: 20, top: 20, bottom: 25 },
|
||||
tooltip: { trigger: "axis" as const },
|
||||
xAxis: { type: "category", data: xLabels.value, axisLine: { lineStyle: { color: "#e2e8f0" } }, axisLabel: { color: "#64748b" } },
|
||||
yAxis: { type: "value", max: 100, name: "%", splitLine: { lineStyle: { color: "#f1f5f9" } }, axisLabel: { color: "#64748b" } },
|
||||
series: [
|
||||
{
|
||||
name: "缓存命中率",
|
||||
type: "bar",
|
||||
barMaxWidth: 24,
|
||||
itemStyle: {
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
color: (params: any) => {
|
||||
const val = params.value;
|
||||
if (val >= 80) return { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "#34d399" }, { offset: 1, color: "#10b981" }] };
|
||||
if (val >= 50) return { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "#fbbf24" }, { offset: 1, color: "#f59e0b" }] };
|
||||
return { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "#f87171" }, { offset: 1, color: "#ef4444" }] };
|
||||
},
|
||||
},
|
||||
data: dataPoints.value.map((p: TrendDataPoint) => p.cache_hit_rate),
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const denyRateOption = computed(() => ({
|
||||
grid: { left: 50, right: 20, top: 20, bottom: 25 },
|
||||
tooltip: { trigger: "axis" as const },
|
||||
xAxis: { type: "category", data: xLabels.value, axisLine: { lineStyle: { color: "#e2e8f0" } }, axisLabel: { color: "#64748b" } },
|
||||
yAxis: { type: "value", max: 100, name: "%", splitLine: { lineStyle: { color: "#f1f5f9" } }, axisLabel: { color: "#64748b" } },
|
||||
series: [
|
||||
{
|
||||
name: "拒绝率",
|
||||
type: "line",
|
||||
smooth: true,
|
||||
symbol: "circle",
|
||||
symbolSize: 6,
|
||||
data: dataPoints.value.map((p: TrendDataPoint) =>
|
||||
p.total_checks > 0 ? Math.round((p.denied_checks / p.total_checks) * 1000) / 10 : 0
|
||||
),
|
||||
itemStyle: { color: "#ef4444" },
|
||||
lineStyle: { width: 2.5 },
|
||||
areaStyle: { color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "rgba(239,68,68,0.2)" }, { offset: 1, color: "rgba(239,68,68,0)" }] } },
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await fetchPermissionTrends(period.value);
|
||||
dataPoints.value = res.data.data_points;
|
||||
} catch {
|
||||
dataPoints.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(loadData);
|
||||
|
||||
defineExpose({ refresh: loadData });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.trend-charts {
|
||||
--trend-ink: #1a2332;
|
||||
--trend-muted: #64748b;
|
||||
--trend-border: #e2e8f0;
|
||||
--trend-radius: 16px;
|
||||
--trend-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.trend-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--trend-border);
|
||||
border-radius: var(--trend-radius);
|
||||
box-shadow: var(--trend-shadow);
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.toolbar-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--trend-ink);
|
||||
}
|
||||
|
||||
.toolbar-desc {
|
||||
font-size: 12px;
|
||||
color: var(--trend-muted);
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--trend-border);
|
||||
border-radius: var(--trend-radius);
|
||||
padding: 18px;
|
||||
box-shadow: var(--trend-shadow);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.chart-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.07);
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chart-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chart-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.chart-icon.blue { background: #eff6ff; color: #3b82f6; }
|
||||
.chart-icon.cyan { background: #ecfeff; color: #06b6d4; }
|
||||
.chart-icon.green { background: #ecfdf5; color: #10b981; }
|
||||
.chart-icon.danger { background: #fef2f2; color: #ef4444; }
|
||||
|
||||
.chart-card h4 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--trend-ink);
|
||||
}
|
||||
|
||||
.chart {
|
||||
height: 240px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.charts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.trend-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user