feat: dashboard 初版

This commit is contained in:
tbphp
2025-07-12 20:24:21 +08:00
parent b0e273060b
commit e07ef88e2a
9 changed files with 1027 additions and 252 deletions

26
web/src/api/dashboard.ts Normal file
View 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");
};

View File

@@ -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;

View File

@@ -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>

View File

@@ -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[];
}