feat: 仪表盘

This commit is contained in:
tbphp
2025-07-12 21:08:15 +08:00
parent 5b2bb5cc71
commit b670cf1a61
2 changed files with 190 additions and 114 deletions

View File

@@ -1,21 +1,15 @@
package handler
import (
"github.com/gin-gonic/gin"
app_errors "gpt-load/internal/errors"
"gpt-load/internal/models"
"gpt-load/internal/response"
"time"
"github.com/gin-gonic/gin"
)
// Stats godoc
// @Summary Get dashboard statistics
// @Description Get statistics for the dashboard cards
// @Tags Dashboard
// @Accept json
// @Produce json
// @Success 200 {object} response.Response{data=models.DashboardStatsResponse}
// @Router /dashboard/stats [get]
// Stats Get dashboard statistics
func (s *Server) Stats(c *gin.Context) {
var activeKeys, invalidKeys, groupCount int64
s.DB.Model(&models.APIKey{}).Where("status = ?", models.KeyStatusActive).Count(&activeKeys)
@@ -37,11 +31,24 @@ func (s *Server) Stats(c *gin.Context) {
return
}
// 计算请求量趋势
reqTrend := 0.0
reqTrendIsGrowth := true
if previousPeriod.TotalRequests > 0 {
// 有前期数据,计算百分比变化
reqTrend = (float64(currentPeriod.TotalRequests-previousPeriod.TotalRequests) / float64(previousPeriod.TotalRequests)) * 100
reqTrendIsGrowth = reqTrend >= 0
} else if currentPeriod.TotalRequests > 0 {
// 前期无数据当前有数据视为100%增长
reqTrend = 100.0
reqTrendIsGrowth = true
} else {
// 前期和当前都无数据
reqTrend = 0.0
reqTrendIsGrowth = true
}
// 计算当前和前期错误率
currentErrorRate := 0.0
if currentPeriod.TotalRequests > 0 {
currentErrorRate = (float64(currentPeriod.TotalFailures) / float64(currentPeriod.TotalRequests)) * 100
@@ -52,7 +59,25 @@ func (s *Server) Stats(c *gin.Context) {
previousErrorRate = (float64(previousPeriod.TotalFailures) / float64(previousPeriod.TotalRequests)) * 100
}
errorRateTrend := currentErrorRate - previousErrorRate
// 计算错误率趋势
errorRateTrend := 0.0
errorRateTrendIsGrowth := false
if previousPeriod.TotalRequests > 0 {
// 有前期数据,计算百分点差异
errorRateTrend = currentErrorRate - previousErrorRate
errorRateTrendIsGrowth = errorRateTrend < 0 // 错误率下降是好事
} else if currentPeriod.TotalRequests > 0 {
// 前期无数据,当前有数据
errorRateTrend = currentErrorRate // 显示当前错误率
errorRateTrendIsGrowth = false // 有错误是坏事(如果错误率>0
if currentErrorRate == 0 {
errorRateTrendIsGrowth = true // 如果当前无错误,标记为正面
}
} else {
// 都无数据
errorRateTrend = 0.0
errorRateTrendIsGrowth = true
}
stats := models.DashboardStatsResponse{
KeyCount: models.StatCard{
@@ -66,28 +91,19 @@ func (s *Server) Stats(c *gin.Context) {
RequestCount: models.StatCard{
Value: float64(currentPeriod.TotalRequests),
Trend: reqTrend,
TrendIsGrowth: reqTrend >= 0,
TrendIsGrowth: reqTrendIsGrowth,
},
ErrorRate: models.StatCard{
Value: currentErrorRate,
Trend: errorRateTrend,
TrendIsGrowth: errorRateTrend < 0, // 错误率下降是好事
TrendIsGrowth: errorRateTrendIsGrowth,
},
}
response.Success(c, stats)
}
// Chart godoc
// @Summary Get dashboard chart data
// @Description Get chart data for the last 24 hours
// @Tags Dashboard
// @Accept json
// @Produce json
// @Param groupId query int false "Group ID"
// @Success 200 {object} response.Response{data=models.ChartData}
// @Router /dashboard/chart [get]
// Chart Get dashboard chart data
func (s *Server) Chart(c *gin.Context) {
groupID := c.Query("groupId")
@@ -95,7 +111,7 @@ func (s *Server) Chart(c *gin.Context) {
twentyFourHoursAgo := now.Add(-24 * time.Hour)
var hourlyStats []models.GroupHourlyStat
query := s.DB.Where("time >= ?", twentyFourHoursAgo)
query := s.DB.Where("time >= ? AND time < ?", twentyFourHoursAgo, now)
if groupID != "" {
query = query.Where("group_id = ?", groupID)
}
@@ -149,7 +165,6 @@ func (s *Server) Chart(c *gin.Context) {
response.Success(c, chartData)
}
type hourlyStatResult struct {
TotalRequests int64
TotalFailures int64

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { getDashboardChart, getGroupList } from "@/api/dashboard";
import type { ChartData, ChartDataset } from "@/types/models";
import type { ChartData } from "@/types/models";
import { getGroupDisplayName } from "@/utils/display";
import { NSelect, NSpin } from "naive-ui";
import { computed, onMounted, ref, watch } from "vue";
@@ -17,16 +18,18 @@ const hoveredPoint = ref<{
} | null>(null);
const tooltipData = ref<{
time: string;
label: string;
value: number;
color: string;
datasets: Array<{
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 chartHeight = 260;
const padding = { top: 40, right: 40, bottom: 60, left: 80 };
// 格式化分组选项
@@ -46,8 +49,13 @@ const dataRange = computed(() => {
const max = Math.max(...allValues, 0);
const min = Math.min(...allValues, 0);
// 如果所有数据都是0设置一个合理的范围
if (max === 0 && min === 0) {
return { min: 0, max: 10 };
}
// 添加一些padding让图表更好看
const paddingValue = (max - min) * 0.1;
const paddingValue = Math.max((max - min) * 0.1, 1);
return {
min: Math.max(0, min - paddingValue),
max: max + paddingValue,
@@ -174,69 +182,70 @@ const handleMouseMove = (event: MouseEvent) => {
}
const rect = chartSvg.value.getBoundingClientRect();
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;
// 考虑SVG的viewBox缩放
const scaleX = 800 / rect.width;
const scaleY = 260 / rect.height;
// 找到最近的数据点
let closestDistance = Infinity;
let closestDatasetIndex = -1;
let closestPointIndex = -1;
const mouseX = (event.clientX - rect.left) * scaleX;
const mouseY = (event.clientY - rect.top) * scaleY;
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);
// 首先找到最接近的X轴位置时间点
let closestXDistance = Infinity;
let closestTimeIndex = -1;
if (distance < 30 && distance < closestDistance) {
closestDistance = distance;
closestDatasetIndex = datasetIndex;
closestPointIndex = pointIndex;
}
});
chartData.value.labels.forEach((_, pointIndex) => {
const x = getXPosition(pointIndex);
const xDistance = Math.abs(mouseX - x);
if (xDistance < closestXDistance) {
closestXDistance = xDistance;
closestTimeIndex = pointIndex;
}
});
if (closestDatasetIndex >= 0 && closestPointIndex >= 0) {
// 如果鼠标距离最近的时间点太远,不显示提示
if (closestXDistance > 50) {
hoveredPoint.value = null;
tooltipData.value = null;
return;
}
// 收集该时间点所有数据集的数据
const datasetsAtTime = chartData.value.datasets.map(dataset => ({
label: dataset.label,
value: dataset.data[closestTimeIndex],
color: dataset.color,
}));
if (closestTimeIndex >= 0) {
hoveredPoint.value = {
datasetIndex: closestDatasetIndex,
pointIndex: closestPointIndex,
datasetIndex: 0, // 不再需要特定的数据集索引
pointIndex: closestTimeIndex,
x: mouseX,
y: mouseY,
};
// 显示 tooltip
const x = getXPosition(closestTimeIndex);
const avgY =
datasetsAtTime.reduce((sum, item) => sum + getYPosition(item.value), 0) /
datasetsAtTime.length;
tooltipPosition.value = {
x,
y: avgY - 20, // 在平均高度上方显示
};
tooltipData.value = {
time: chartData.value.labels[closestTimeIndex],
datasets: datasetsAtTime,
};
} 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;
@@ -249,7 +258,7 @@ const fetchGroups = async () => {
groupOptions.value = [
{ label: "全部分组", value: null },
...response.data.map(group => ({
label: group.display_name || group.name,
label: getGroupDisplayName(group),
value: group.id || 0,
})),
];
@@ -290,13 +299,16 @@ onMounted(() => {
<template>
<div class="chart-container">
<div class="chart-header">
<h3 class="chart-title">24小时请求趋势</h3>
<div class="chart-title-section">
<h3 class="chart-title">24小时请求趋势</h3>
<p class="chart-subtitle">实时监控系统请求状态</p>
</div>
<n-select
v-model:value="selectedGroup"
:options="groupOptions as any"
placeholder="选择分组"
placeholder="全部分组"
size="small"
style="width: 120px"
style="width: 150px"
clearable
@update:value="fetchChartData"
/>
@@ -305,7 +317,7 @@ onMounted(() => {
<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 }" />
<div class="legend-indicator" :style="{ backgroundColor: dataset.color }" />
<span class="legend-label">{{ dataset.label }}</span>
</div>
</div>
@@ -313,8 +325,7 @@ onMounted(() => {
<div class="chart-wrapper">
<svg
ref="chartSvg"
:width="chartWidth"
:height="chartHeight"
viewBox="0 0 800 260"
class="chart-svg"
@mousemove="handleMouseMove"
@mouseleave="hideTooltip"
@@ -435,11 +446,8 @@ onMounted(() => {
stroke-width="2"
class="data-point"
:class="{
'point-hover':
hoveredPoint?.datasetIndex === datasetIndex &&
hoveredPoint?.pointIndex === pointIndex,
'point-hover': hoveredPoint?.pointIndex === pointIndex,
}"
@mouseenter="showTooltip($event, dataset, pointIndex, value)"
/>
</g>
</g>
@@ -468,9 +476,9 @@ onMounted(() => {
}"
>
<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 v-for="dataset in tooltipData.datasets" :key="dataset.label" class="tooltip-value">
<span class="tooltip-color" :style="{ backgroundColor: dataset.color }" />
{{ dataset.label }}: {{ formatNumber(dataset.value) }}
</div>
</div>
</div>
@@ -497,12 +505,17 @@ onMounted(() => {
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
align-items: flex-start;
margin-bottom: 20px;
gap: 16px;
}
.chart-title-section {
flex: 1;
}
.chart-title {
margin: 0;
margin: 0 0 4px 0;
font-size: 24px;
font-weight: 600;
background: linear-gradient(45deg, #fff, #f0f0f0);
@@ -511,37 +524,70 @@ onMounted(() => {
background-clip: text;
}
.chart-subtitle {
margin: 0;
font-size: 14px;
color: rgba(255, 255, 255, 0.8);
font-weight: 400;
}
.chart-content {
background: rgba(255, 255, 255, 0.95);
border-radius: 12px;
padding: 20px;
padding: 12px;
color: #333;
}
.chart-legend {
display: flex;
justify-content: center;
gap: 24px;
margin-bottom: 20px;
gap: 12px;
margin-bottom: 12px;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
font-weight: 600;
font-size: 13px;
color: #475569;
padding: 8px 16px;
background: rgba(255, 255, 255, 0.6);
border-radius: 20px;
border: 1px solid rgba(226, 232, 240, 0.6);
transition: all 0.2s ease;
}
.legend-color {
width: 16px;
height: 16px;
border-radius: 50%;
.legend-item:hover {
background: rgba(255, 255, 255, 0.9);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.legend-indicator {
width: 12px;
height: 12px;
border-radius: 3px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
position: relative;
}
.legend-indicator::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 6px;
height: 6px;
background: rgba(255, 255, 255, 0.3);
border-radius: 50%;
}
.legend-label {
font-size: 14px;
color: #666;
font-size: 13px;
color: #334155;
}
.chart-wrapper {
@@ -584,27 +630,42 @@ onMounted(() => {
.chart-tooltip {
position: absolute;
background: rgba(0, 0, 0, 0.8);
background: rgba(0, 0, 0, 0.9);
color: white;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
padding: 12px 16px;
border-radius: 8px;
font-size: 13px;
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);
backdrop-filter: blur(8px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
border: 1px solid rgba(255, 255, 255, 0.1);
min-width: 140px;
max-width: 220px;
}
.tooltip-time {
font-weight: 600;
margin-bottom: 4px;
font-weight: 700;
margin-bottom: 8px;
text-align: center;
color: #e2e8f0;
font-size: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
padding-bottom: 6px;
}
.tooltip-value {
display: flex;
align-items: center;
gap: 6px;
gap: 8px;
font-weight: 600;
margin-bottom: 4px;
font-size: 12px;
}
.tooltip-value:last-child {
margin-bottom: 0;
}
.tooltip-color {
@@ -618,7 +679,7 @@ onMounted(() => {
flex-direction: column;
align-items: center;
justify-content: center;
height: 300px;
height: 260px;
color: white;
}