feat: dashboard 初版
This commit is contained in:
26
web/src/api/dashboard.ts
Normal file
26
web/src/api/dashboard.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { ChartData, DashboardStatsResponse, Group } from "@/types/models";
|
||||
import http from "@/utils/http";
|
||||
|
||||
/**
|
||||
* 获取仪表盘基础统计数据
|
||||
*/
|
||||
export const getDashboardStats = () => {
|
||||
return http.get<DashboardStatsResponse>("/dashboard/stats");
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取仪表盘图表数据
|
||||
* @param groupId 可选的分组ID
|
||||
*/
|
||||
export const getDashboardChart = (groupId?: number) => {
|
||||
return http.get<ChartData>("/dashboard/chart", {
|
||||
params: groupId ? { groupId } : {},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取用于筛选的分组列表
|
||||
*/
|
||||
export const getGroupList = () => {
|
||||
return http.get<Group[]>("/groups/list");
|
||||
};
|
@@ -1,52 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { NCard, NGrid, NGridItem, NSpace, NTag } from "naive-ui";
|
||||
import { getDashboardStats } from "@/api/dashboard";
|
||||
import type { DashboardStatsResponse } from "@/types/models";
|
||||
import { NCard, NGrid, NGridItem, NSpace, NTag, NTooltip } from "naive-ui";
|
||||
import { onMounted, ref } from "vue";
|
||||
|
||||
// 模拟数据
|
||||
const stats = ref([
|
||||
{
|
||||
title: "总请求数",
|
||||
value: "125,842",
|
||||
icon: "📈",
|
||||
color: "var(--primary-gradient)",
|
||||
trend: "+12.5%",
|
||||
trendUp: true,
|
||||
},
|
||||
{
|
||||
title: "活跃连接",
|
||||
value: "1,234",
|
||||
icon: "🔗",
|
||||
color: "var(--success-gradient)",
|
||||
trend: "+5.2%",
|
||||
trendUp: true,
|
||||
},
|
||||
{
|
||||
title: "响应时间",
|
||||
value: "245ms",
|
||||
icon: "⚡",
|
||||
color: "var(--warning-gradient)",
|
||||
trend: "-8.1%",
|
||||
trendUp: false,
|
||||
},
|
||||
{
|
||||
title: "错误率",
|
||||
value: "0.12%",
|
||||
icon: "🛡️",
|
||||
color: "var(--secondary-gradient)",
|
||||
trend: "-2.3%",
|
||||
trendUp: false,
|
||||
},
|
||||
]);
|
||||
|
||||
// 统计数据
|
||||
const stats = ref<DashboardStatsResponse | null>(null);
|
||||
const loading = ref(true);
|
||||
const animatedValues = ref<Record<string, number>>({});
|
||||
|
||||
onMounted(() => {
|
||||
// 动画效果
|
||||
stats.value.forEach((stat, index) => {
|
||||
// 格式化数值显示
|
||||
const formatValue = (value: number, type: "count" | "rate" = "count"): string => {
|
||||
if (type === "rate") {
|
||||
return `${value.toFixed(2)}%`;
|
||||
}
|
||||
if (value >= 10000) {
|
||||
return `${(value / 10000).toFixed(1)}w`;
|
||||
}
|
||||
if (value >= 1000) {
|
||||
return `${(value / 1000).toFixed(1)}k`;
|
||||
}
|
||||
return value.toString();
|
||||
};
|
||||
|
||||
// 格式化趋势显示
|
||||
const formatTrend = (trend: number): string => {
|
||||
const sign = trend >= 0 ? "+" : "";
|
||||
return `${sign}${trend.toFixed(1)}%`;
|
||||
};
|
||||
|
||||
// 获取统计数据
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
const response = await getDashboardStats();
|
||||
stats.value = response.data;
|
||||
|
||||
// 添加动画效果
|
||||
setTimeout(() => {
|
||||
animatedValues.value[stat.title] = 1;
|
||||
}, index * 150);
|
||||
});
|
||||
animatedValues.value = {
|
||||
key_count: 1,
|
||||
group_count: 1,
|
||||
request_count: 1,
|
||||
error_rate: 1,
|
||||
};
|
||||
}, 150);
|
||||
} catch (error) {
|
||||
console.error("获取统计数据失败:", error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchStats();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -54,32 +61,124 @@ onMounted(() => {
|
||||
<div class="stats-container">
|
||||
<n-space vertical size="medium">
|
||||
<n-grid :cols="4" :x-gap="20" :y-gap="20" responsive="screen">
|
||||
<n-grid-item v-for="(stat, index) in stats" :key="stat.title" span="1">
|
||||
<n-card
|
||||
:bordered="false"
|
||||
class="stat-card"
|
||||
:style="{ animationDelay: `${index * 0.05}s` }"
|
||||
>
|
||||
<!-- 秘钥数量 -->
|
||||
<n-grid-item span="1">
|
||||
<n-card :bordered="false" class="stat-card" style="animation-delay: 0s">
|
||||
<div class="stat-header">
|
||||
<div class="stat-icon" :style="{ background: stat.color }">
|
||||
{{ stat.icon }}
|
||||
</div>
|
||||
<n-tag :type="stat.trendUp ? 'success' : 'error'" size="small" class="stat-trend">
|
||||
{{ stat.trend }}
|
||||
</n-tag>
|
||||
<div class="stat-icon key-icon">🔑</div>
|
||||
<n-tooltip v-if="stats?.key_count.sub_value" trigger="hover">
|
||||
<template #trigger>
|
||||
<n-tag type="error" size="small" class="stat-trend">
|
||||
{{ stats.key_count.sub_value }}
|
||||
</n-tag>
|
||||
</template>
|
||||
{{ stats.key_count.sub_value_tip }}
|
||||
</n-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stat.value }}</div>
|
||||
<div class="stat-title">{{ stat.title }}</div>
|
||||
<div class="stat-value">
|
||||
{{ stats ? formatValue(stats.key_count.value) : "--" }}
|
||||
</div>
|
||||
<div class="stat-title">秘钥数量</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-bar">
|
||||
<div
|
||||
class="stat-bar-fill"
|
||||
class="stat-bar-fill key-bar"
|
||||
:style="{
|
||||
background: stat.color,
|
||||
width: `${animatedValues[stat.title] * 100}%`,
|
||||
width: `${animatedValues.key_count * 100}%`,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<!-- 分组数量 -->
|
||||
<n-grid-item span="1">
|
||||
<n-card :bordered="false" class="stat-card" style="animation-delay: 0.05s">
|
||||
<div class="stat-header">
|
||||
<div class="stat-icon group-icon">📁</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">
|
||||
{{ stats ? formatValue(stats.group_count.value) : "--" }}
|
||||
</div>
|
||||
<div class="stat-title">分组数量</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-bar">
|
||||
<div
|
||||
class="stat-bar-fill group-bar"
|
||||
:style="{
|
||||
width: `${animatedValues.group_count * 100}%`,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<!-- 24小时请求 -->
|
||||
<n-grid-item span="1">
|
||||
<n-card :bordered="false" class="stat-card" style="animation-delay: 0.1s">
|
||||
<div class="stat-header">
|
||||
<div class="stat-icon request-icon">📈</div>
|
||||
<n-tag
|
||||
v-if="stats?.request_count && stats.request_count.trend !== undefined"
|
||||
:type="stats?.request_count.trend_is_growth ? 'success' : 'error'"
|
||||
size="small"
|
||||
class="stat-trend"
|
||||
>
|
||||
{{ stats ? formatTrend(stats.request_count.trend) : "--" }}
|
||||
</n-tag>
|
||||
</div>
|
||||
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">
|
||||
{{ stats ? formatValue(stats.request_count.value) : "--" }}
|
||||
</div>
|
||||
<div class="stat-title">24小时请求</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-bar">
|
||||
<div
|
||||
class="stat-bar-fill request-bar"
|
||||
:style="{
|
||||
width: `${animatedValues.request_count * 100}%`,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<!-- 24小时错误率 -->
|
||||
<n-grid-item span="1">
|
||||
<n-card :bordered="false" class="stat-card" style="animation-delay: 0.15s">
|
||||
<div class="stat-header">
|
||||
<div class="stat-icon error-icon">🛡️</div>
|
||||
<n-tag
|
||||
v-if="stats?.error_rate.trend !== 0"
|
||||
:type="stats?.error_rate.trend_is_growth ? 'success' : 'error'"
|
||||
size="small"
|
||||
class="stat-trend"
|
||||
>
|
||||
{{ stats ? formatTrend(stats.error_rate.trend) : "--" }}
|
||||
</n-tag>
|
||||
</div>
|
||||
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">
|
||||
{{ stats ? formatValue(stats.error_rate.value, "rate") : "--" }}
|
||||
</div>
|
||||
<div class="stat-title">24小时错误率</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-bar">
|
||||
<div
|
||||
class="stat-bar-fill error-bar"
|
||||
:style="{
|
||||
width: `${animatedValues.error_rate * 100}%`,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
@@ -130,6 +229,22 @@ onMounted(() => {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.key-icon {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.group-icon {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
}
|
||||
|
||||
.request-icon {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
|
||||
}
|
||||
|
||||
.stat-trend {
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -177,6 +292,22 @@ onMounted(() => {
|
||||
transition-delay: 0.2s;
|
||||
}
|
||||
|
||||
.key-bar {
|
||||
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.group-bar {
|
||||
background: linear-gradient(90deg, #f093fb 0%, #f5576c 100%);
|
||||
}
|
||||
|
||||
.request-bar {
|
||||
background: linear-gradient(90deg, #4facfe 0%, #00f2fe 100%);
|
||||
}
|
||||
|
||||
.error-bar {
|
||||
background: linear-gradient(90deg, #43e97b 0%, #38f9d7 100%);
|
||||
}
|
||||
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
@@ -1,44 +1,163 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { getDashboardChart, getGroupList } from "@/api/dashboard";
|
||||
import type { ChartData, ChartDataset } from "@/types/models";
|
||||
import { NSelect, NSpin } from "naive-ui";
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
|
||||
// 模拟图表数据
|
||||
const chartData = ref({
|
||||
labels: ["00:00", "04:00", "08:00", "12:00", "16:00", "20:00", "24:00"],
|
||||
datasets: [
|
||||
{
|
||||
label: "请求数量",
|
||||
data: [120, 150, 300, 450, 380, 280, 200],
|
||||
color: "#667eea",
|
||||
},
|
||||
{
|
||||
label: "响应时间",
|
||||
data: [200, 180, 250, 300, 220, 190, 160],
|
||||
color: "#f093fb",
|
||||
},
|
||||
],
|
||||
// 图表数据
|
||||
const chartData = ref<ChartData | null>(null);
|
||||
const selectedGroup = ref<number | null>(null);
|
||||
const loading = ref(true);
|
||||
const animationProgress = ref(0);
|
||||
const hoveredPoint = ref<{
|
||||
datasetIndex: number;
|
||||
pointIndex: number;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const tooltipData = ref<{
|
||||
time: string;
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
} | null>(null);
|
||||
const tooltipPosition = ref({ x: 0, y: 0 });
|
||||
const chartSvg = ref<SVGElement>();
|
||||
|
||||
// 图表尺寸和边距
|
||||
const chartWidth = 800;
|
||||
const chartHeight = 400;
|
||||
const padding = { top: 40, right: 40, bottom: 60, left: 80 };
|
||||
|
||||
// 格式化分组选项
|
||||
const groupOptions = ref<Array<{ label: string; value: number | null }>>([]);
|
||||
|
||||
// 计算有效的绘图区域
|
||||
const plotWidth = chartWidth - padding.left - padding.right;
|
||||
const plotHeight = chartHeight - padding.top - padding.bottom;
|
||||
|
||||
// 计算数据的最大值和最小值
|
||||
const dataRange = computed(() => {
|
||||
if (!chartData.value) {
|
||||
return { min: 0, max: 100 };
|
||||
}
|
||||
|
||||
const allValues = chartData.value.datasets.flatMap(d => d.data);
|
||||
const max = Math.max(...allValues, 0);
|
||||
const min = Math.min(...allValues, 0);
|
||||
|
||||
// 添加一些padding让图表更好看
|
||||
const paddingValue = (max - min) * 0.1;
|
||||
return {
|
||||
min: Math.max(0, min - paddingValue),
|
||||
max: max + paddingValue,
|
||||
};
|
||||
});
|
||||
|
||||
const chartContainer = ref<HTMLElement>();
|
||||
const animationProgress = ref(0);
|
||||
// 生成Y轴刻度
|
||||
const yTicks = computed(() => {
|
||||
const { min, max } = dataRange.value;
|
||||
const range = max - min;
|
||||
const tickCount = 5;
|
||||
const step = range / (tickCount - 1);
|
||||
|
||||
return Array.from({ length: tickCount }, (_, i) => min + i * step);
|
||||
});
|
||||
|
||||
// 生成可见的X轴标签(避免重叠)
|
||||
const visibleLabels = computed(() => {
|
||||
if (!chartData.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const labels = chartData.value.labels;
|
||||
const maxLabels = 8; // 最多显示8个标签
|
||||
const step = Math.ceil(labels.length / maxLabels);
|
||||
|
||||
return labels.map((label, index) => ({ text: label, index })).filter((_, i) => i % step === 0);
|
||||
});
|
||||
|
||||
// 位置计算函数
|
||||
const getXPosition = (index: number) => {
|
||||
if (!chartData.value) {
|
||||
return 0;
|
||||
}
|
||||
const totalPoints = chartData.value.labels.length;
|
||||
return padding.left + (index / (totalPoints - 1)) * plotWidth;
|
||||
};
|
||||
|
||||
const getYPosition = (value: number) => {
|
||||
const { min, max } = dataRange.value;
|
||||
const ratio = (value - min) / (max - min);
|
||||
return padding.top + (1 - ratio) * plotHeight;
|
||||
};
|
||||
|
||||
// 生成线条路径
|
||||
const generateLinePath = (data: number[]) => {
|
||||
if (!data.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// 生成SVG路径
|
||||
const generatePath = (data: number[]) => {
|
||||
const points = data.map((value, index) => {
|
||||
const x = (index / (data.length - 1)) * 380 + 10;
|
||||
const y = 200 - (value / 500) * 180 - 10;
|
||||
const x = getXPosition(index);
|
||||
const y = getYPosition(value);
|
||||
return `${x},${y}`;
|
||||
});
|
||||
|
||||
return `M ${points.join(" L ")}`;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// 简单的动画效果
|
||||
// 生成填充区域路径
|
||||
const generateAreaPath = (data: number[]) => {
|
||||
if (!data.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const points = data.map((value, index) => {
|
||||
const x = getXPosition(index);
|
||||
const y = getYPosition(value);
|
||||
return `${x},${y}`;
|
||||
});
|
||||
|
||||
const baseY = getYPosition(dataRange.value.min);
|
||||
const firstX = getXPosition(0);
|
||||
const lastX = getXPosition(data.length - 1);
|
||||
|
||||
return `M ${firstX},${baseY} L ${points.join(" L ")} L ${lastX},${baseY} Z`;
|
||||
};
|
||||
|
||||
// 数字格式化
|
||||
const formatNumber = (value: number) => {
|
||||
if (value >= 1000000) {
|
||||
return `${(value / 1000000).toFixed(1)}M`;
|
||||
} else if (value >= 1000) {
|
||||
return `${(value / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return Math.round(value).toString();
|
||||
};
|
||||
|
||||
// 动画相关
|
||||
const animatedStroke = ref("0");
|
||||
const animatedOffset = ref("0");
|
||||
|
||||
const startAnimation = () => {
|
||||
if (!chartData.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算总路径长度(近似)
|
||||
const totalLength = plotWidth + plotHeight;
|
||||
animatedStroke.value = `${totalLength}`;
|
||||
animatedOffset.value = `${totalLength}`;
|
||||
|
||||
let start = 0;
|
||||
const animate = (timestamp: number) => {
|
||||
if (!start) {
|
||||
start = timestamp;
|
||||
}
|
||||
const progress = Math.min((timestamp - start) / 2000, 1);
|
||||
const progress = Math.min((timestamp - start) / 1500, 1);
|
||||
|
||||
animatedOffset.value = `${totalLength * (1 - progress)}`;
|
||||
animationProgress.value = progress;
|
||||
|
||||
if (progress < 1) {
|
||||
@@ -46,206 +165,521 @@ onMounted(() => {
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
// 鼠标交互
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
if (!chartData.value || !chartSvg.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = chartSvg.value.getBoundingClientRect();
|
||||
const mouseX = event.clientX - rect.left;
|
||||
const mouseY = event.clientY - rect.top;
|
||||
|
||||
// 找到最近的数据点
|
||||
let closestDistance = Infinity;
|
||||
let closestDatasetIndex = -1;
|
||||
let closestPointIndex = -1;
|
||||
|
||||
chartData.value.datasets.forEach((dataset, datasetIndex) => {
|
||||
dataset.data.forEach((value, pointIndex) => {
|
||||
const x = getXPosition(pointIndex);
|
||||
const y = getYPosition(value);
|
||||
const distance = Math.sqrt((mouseX - x) ** 2 + (mouseY - y) ** 2);
|
||||
|
||||
if (distance < 30 && distance < closestDistance) {
|
||||
closestDistance = distance;
|
||||
closestDatasetIndex = datasetIndex;
|
||||
closestPointIndex = pointIndex;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (closestDatasetIndex >= 0 && closestPointIndex >= 0) {
|
||||
hoveredPoint.value = {
|
||||
datasetIndex: closestDatasetIndex,
|
||||
pointIndex: closestPointIndex,
|
||||
x: mouseX,
|
||||
y: mouseY,
|
||||
};
|
||||
} else {
|
||||
hoveredPoint.value = null;
|
||||
tooltipData.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const showTooltip = (
|
||||
event: MouseEvent,
|
||||
dataset: ChartDataset,
|
||||
pointIndex: number,
|
||||
value: number
|
||||
) => {
|
||||
if (!chartData.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = (event.target as SVGElement).getBoundingClientRect();
|
||||
const containerRect = chartSvg.value?.getBoundingClientRect();
|
||||
|
||||
if (containerRect) {
|
||||
tooltipPosition.value = {
|
||||
x: rect.left - containerRect.left + rect.width / 2,
|
||||
y: rect.top - containerRect.top - 10,
|
||||
};
|
||||
}
|
||||
|
||||
tooltipData.value = {
|
||||
time: chartData.value.labels[pointIndex],
|
||||
label: dataset.label,
|
||||
value,
|
||||
color: dataset.color,
|
||||
};
|
||||
};
|
||||
|
||||
const hideTooltip = () => {
|
||||
hoveredPoint.value = null;
|
||||
tooltipData.value = null;
|
||||
};
|
||||
|
||||
// 获取分组列表
|
||||
const fetchGroups = async () => {
|
||||
try {
|
||||
const response = await getGroupList();
|
||||
groupOptions.value = [
|
||||
{ label: "全部分组", value: null },
|
||||
...response.data.map(group => ({
|
||||
label: group.display_name || group.name,
|
||||
value: group.id || 0,
|
||||
})),
|
||||
];
|
||||
} catch (error) {
|
||||
console.error("获取分组列表失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取图表数据
|
||||
const fetchChartData = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
const response = await getDashboardChart(selectedGroup.value || undefined);
|
||||
chartData.value = response.data;
|
||||
|
||||
// 延迟启动动画,确保DOM更新完成
|
||||
setTimeout(() => {
|
||||
startAnimation();
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
console.error("获取图表数据失败:", error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 监听分组选择变化
|
||||
watch(selectedGroup, () => {
|
||||
fetchChartData();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
fetchGroups();
|
||||
fetchChartData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chart-container">
|
||||
<n-card class="chart-card modern-card" :bordered="false">
|
||||
<template #header>
|
||||
<div class="chart-header">
|
||||
<h3 class="chart-title">性能监控</h3>
|
||||
<p class="chart-subtitle">实时系统性能指标</p>
|
||||
<div class="chart-header">
|
||||
<h3 class="chart-title">24小时请求趋势</h3>
|
||||
<n-select
|
||||
v-model:value="selectedGroup"
|
||||
:options="groupOptions as any"
|
||||
placeholder="选择分组"
|
||||
size="small"
|
||||
style="width: 120px"
|
||||
clearable
|
||||
@update:value="fetchChartData"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="chartData" class="chart-content">
|
||||
<div class="chart-legend">
|
||||
<div v-for="dataset in chartData.datasets" :key="dataset.label" class="legend-item">
|
||||
<div class="legend-color" :style="{ backgroundColor: dataset.color }" />
|
||||
<span class="legend-label">{{ dataset.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div ref="chartContainer" class="chart-content">
|
||||
<div class="chart-legend">
|
||||
<div v-for="dataset in chartData.datasets" :key="dataset.label" class="legend-item">
|
||||
<div class="legend-color" :style="{ backgroundColor: dataset.color }" />
|
||||
<span class="legend-label">{{ dataset.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-area">
|
||||
<div class="chart-grid">
|
||||
<div
|
||||
v-for="(label, index) in chartData.labels"
|
||||
:key="label"
|
||||
class="grid-line"
|
||||
:style="{ left: `${(index / (chartData.labels.length - 1)) * 100}%` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<svg class="chart-svg" viewBox="0 0 400 200">
|
||||
<!-- 数据线条 -->
|
||||
<g v-for="dataset in chartData.datasets" :key="dataset.label">
|
||||
<div class="chart-wrapper">
|
||||
<svg
|
||||
ref="chartSvg"
|
||||
:width="chartWidth"
|
||||
:height="chartHeight"
|
||||
class="chart-svg"
|
||||
@mousemove="handleMouseMove"
|
||||
@mouseleave="hideTooltip"
|
||||
>
|
||||
<!-- 背景网格 -->
|
||||
<defs>
|
||||
<pattern id="grid" width="40" height="30" patternUnits="userSpaceOnUse">
|
||||
<path
|
||||
:d="generatePath(dataset.data)"
|
||||
:stroke="dataset.color"
|
||||
stroke-width="3"
|
||||
d="M 40 0 L 0 0 0 30"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="chart-line"
|
||||
:style="{
|
||||
strokeDasharray: '1000',
|
||||
strokeDashoffset: `${1000 * (1 - animationProgress)}`,
|
||||
}"
|
||||
stroke="#f0f0f0"
|
||||
stroke-width="1"
|
||||
opacity="0.3"
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||
|
||||
<!-- 数据点 -->
|
||||
<g v-for="(value, index) in dataset.data" :key="index">
|
||||
<circle
|
||||
:cx="(index / (dataset.data.length - 1)) * 380 + 10"
|
||||
:cy="200 - (value / 500) * 180 - 10"
|
||||
:r="animationProgress > index / dataset.data.length ? 4 : 0"
|
||||
:fill="dataset.color"
|
||||
class="chart-point"
|
||||
/>
|
||||
</g>
|
||||
<!-- Y轴刻度线和标签 -->
|
||||
<g class="y-axis">
|
||||
<line
|
||||
:x1="padding.left"
|
||||
:y1="padding.top"
|
||||
:x2="padding.left"
|
||||
:y2="chartHeight - padding.bottom"
|
||||
stroke="#e0e0e0"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<g v-for="(tick, index) in yTicks" :key="index">
|
||||
<line
|
||||
:x1="padding.left - 5"
|
||||
:y1="getYPosition(tick)"
|
||||
:x2="padding.left"
|
||||
:y2="getYPosition(tick)"
|
||||
stroke="#666"
|
||||
stroke-width="1"
|
||||
/>
|
||||
<text
|
||||
:x="padding.left - 10"
|
||||
:y="getYPosition(tick) + 4"
|
||||
text-anchor="end"
|
||||
class="axis-label"
|
||||
>
|
||||
{{ formatNumber(tick) }}
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
</g>
|
||||
|
||||
<div class="chart-labels">
|
||||
<div
|
||||
v-for="(label, index) in chartData.labels"
|
||||
:key="label"
|
||||
class="chart-label"
|
||||
:style="{ left: `${(index / (chartData.labels.length - 1)) * 100}%` }"
|
||||
>
|
||||
{{ label }}
|
||||
</div>
|
||||
<!-- X轴刻度线和标签 -->
|
||||
<g class="x-axis">
|
||||
<line
|
||||
:x1="padding.left"
|
||||
:y1="chartHeight - padding.bottom"
|
||||
:x2="chartWidth - padding.right"
|
||||
:y2="chartHeight - padding.bottom"
|
||||
stroke="#e0e0e0"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<g v-for="(label, index) in visibleLabels" :key="index">
|
||||
<line
|
||||
:x1="getXPosition(label.index)"
|
||||
:y1="chartHeight - padding.bottom"
|
||||
:x2="getXPosition(label.index)"
|
||||
:y2="chartHeight - padding.bottom + 5"
|
||||
stroke="#666"
|
||||
stroke-width="1"
|
||||
/>
|
||||
<text
|
||||
:x="getXPosition(label.index)"
|
||||
:y="chartHeight - padding.bottom + 18"
|
||||
text-anchor="middle"
|
||||
class="axis-label"
|
||||
>
|
||||
{{ label.text }}
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- 数据线条 -->
|
||||
<g v-for="(dataset, datasetIndex) in chartData.datasets" :key="dataset.label">
|
||||
<!-- 渐变定义 -->
|
||||
<defs>
|
||||
<linearGradient :id="`gradient-${datasetIndex}`" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" :stop-color="dataset.color" stop-opacity="0.3" />
|
||||
<stop offset="100%" :stop-color="dataset.color" stop-opacity="0.05" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- 填充区域 -->
|
||||
<path
|
||||
:d="generateAreaPath(dataset.data)"
|
||||
:fill="`url(#gradient-${datasetIndex})`"
|
||||
class="area-path"
|
||||
/>
|
||||
|
||||
<!-- 主线条 -->
|
||||
<path
|
||||
:d="generateLinePath(dataset.data)"
|
||||
:stroke="dataset.color"
|
||||
stroke-width="3"
|
||||
fill="none"
|
||||
class="line-path"
|
||||
:style="{
|
||||
strokeDasharray: animatedStroke,
|
||||
strokeDashoffset: animatedOffset,
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.1))',
|
||||
}"
|
||||
/>
|
||||
|
||||
<!-- 数据点 -->
|
||||
<g v-for="(value, pointIndex) in dataset.data" :key="pointIndex">
|
||||
<circle
|
||||
:cx="getXPosition(pointIndex)"
|
||||
:cy="getYPosition(value)"
|
||||
r="4"
|
||||
:fill="dataset.color"
|
||||
:stroke="dataset.color"
|
||||
stroke-width="2"
|
||||
class="data-point"
|
||||
:class="{
|
||||
'point-hover':
|
||||
hoveredPoint?.datasetIndex === datasetIndex &&
|
||||
hoveredPoint?.pointIndex === pointIndex,
|
||||
}"
|
||||
@mouseenter="showTooltip($event, dataset, pointIndex, value)"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- 悬停指示线 -->
|
||||
<line
|
||||
v-if="hoveredPoint"
|
||||
:x1="getXPosition(hoveredPoint.pointIndex)"
|
||||
:y1="padding.top"
|
||||
:x2="getXPosition(hoveredPoint.pointIndex)"
|
||||
:y2="chartHeight - padding.bottom"
|
||||
stroke="#999"
|
||||
stroke-width="1"
|
||||
stroke-dasharray="5,5"
|
||||
opacity="0.7"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- 提示框 -->
|
||||
<div
|
||||
v-if="tooltipData"
|
||||
class="chart-tooltip"
|
||||
:style="{
|
||||
left: tooltipPosition.x + 'px',
|
||||
top: tooltipPosition.y + 'px',
|
||||
}"
|
||||
>
|
||||
<div class="tooltip-time">{{ tooltipData.time }}</div>
|
||||
<div class="tooltip-value">
|
||||
<span class="tooltip-color" :style="{ backgroundColor: tooltipData.color }" />
|
||||
{{ tooltipData.label }}: {{ formatNumber(tooltipData.value) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
<div v-else class="chart-loading">
|
||||
<n-spin size="large" />
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chart-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(31, 38, 135, 0.37);
|
||||
backdrop-filter: blur(4px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
text-align: center;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.chart-subtitle {
|
||||
font-size: 0.9rem;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(45deg, #fff, #f0f0f0);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.chart-content {
|
||||
position: relative;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
font-size: 0.9rem;
|
||||
color: #374151;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.chart-area {
|
||||
.chart-wrapper {
|
||||
position: relative;
|
||||
height: 240px;
|
||||
background: linear-gradient(180deg, rgba(102, 126, 234, 0.02) 0%, rgba(102, 126, 234, 0.08) 100%);
|
||||
border-radius: var(--border-radius-md);
|
||||
border: 1px solid rgba(102, 126, 234, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chart-grid {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.grid-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.chart-svg {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.chart-line {
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1));
|
||||
transition: stroke-dashoffset 0.2s ease-out;
|
||||
.axis-label {
|
||||
fill: #666;
|
||||
font-size: 12px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.chart-point {
|
||||
transition: r 0.2s ease;
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
|
||||
.line-path {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.chart-point:hover {
|
||||
.area-path {
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.data-point {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.data-point:hover,
|
||||
.point-hover {
|
||||
r: 6;
|
||||
filter: drop-shadow(0 0 8px rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
|
||||
.chart-labels {
|
||||
.chart-tooltip {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 40px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
transform: translateX(-50%) translateY(-100%);
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(4px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.tooltip-time {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tooltip-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chart-label {
|
||||
position: absolute;
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
backdrop-filter: blur(4px);
|
||||
.tooltip-color {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.chart-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 300px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.chart-loading p {
|
||||
margin-top: 16px;
|
||||
font-size: 16px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.chart-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.chart-svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
animation: fadeInUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
animation: fadeInUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
.legend-item:nth-child(2) {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
|
||||
.legend-item:nth-child(3) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
</style>
|
||||
|
@@ -143,3 +143,33 @@ export interface GroupRequestStat {
|
||||
display_name: string;
|
||||
request_count: number;
|
||||
}
|
||||
|
||||
// 仪表盘统计卡片数据
|
||||
export interface StatCard {
|
||||
value: number;
|
||||
sub_value?: number;
|
||||
sub_value_tip?: string;
|
||||
trend: number;
|
||||
trend_is_growth: boolean;
|
||||
}
|
||||
|
||||
// 仪表盘基础统计响应
|
||||
export interface DashboardStatsResponse {
|
||||
key_count: StatCard;
|
||||
group_count: StatCard;
|
||||
request_count: StatCard;
|
||||
error_rate: StatCard;
|
||||
}
|
||||
|
||||
// 图表数据集
|
||||
export interface ChartDataset {
|
||||
label: string;
|
||||
data: number[];
|
||||
color: string;
|
||||
}
|
||||
|
||||
// 图表数据
|
||||
export interface ChartData {
|
||||
labels: string[];
|
||||
datasets: ChartDataset[];
|
||||
}
|
||||
|
Reference in New Issue
Block a user