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 package handler
import ( import (
"github.com/gin-gonic/gin"
app_errors "gpt-load/internal/errors" app_errors "gpt-load/internal/errors"
"gpt-load/internal/models" "gpt-load/internal/models"
"gpt-load/internal/response" "gpt-load/internal/response"
"time" "time"
"github.com/gin-gonic/gin"
) )
// Stats godoc // Stats Get dashboard statistics
// @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]
func (s *Server) Stats(c *gin.Context) { func (s *Server) Stats(c *gin.Context) {
var activeKeys, invalidKeys, groupCount int64 var activeKeys, invalidKeys, groupCount int64
s.DB.Model(&models.APIKey{}).Where("status = ?", models.KeyStatusActive).Count(&activeKeys) s.DB.Model(&models.APIKey{}).Where("status = ?", models.KeyStatusActive).Count(&activeKeys)
@@ -37,11 +31,24 @@ func (s *Server) Stats(c *gin.Context) {
return return
} }
// 计算请求量趋势
reqTrend := 0.0 reqTrend := 0.0
reqTrendIsGrowth := true
if previousPeriod.TotalRequests > 0 { if previousPeriod.TotalRequests > 0 {
// 有前期数据,计算百分比变化
reqTrend = (float64(currentPeriod.TotalRequests-previousPeriod.TotalRequests) / float64(previousPeriod.TotalRequests)) * 100 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 currentErrorRate := 0.0
if currentPeriod.TotalRequests > 0 { if currentPeriod.TotalRequests > 0 {
currentErrorRate = (float64(currentPeriod.TotalFailures) / float64(currentPeriod.TotalRequests)) * 100 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 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{ stats := models.DashboardStatsResponse{
KeyCount: models.StatCard{ KeyCount: models.StatCard{
@@ -66,28 +91,19 @@ func (s *Server) Stats(c *gin.Context) {
RequestCount: models.StatCard{ RequestCount: models.StatCard{
Value: float64(currentPeriod.TotalRequests), Value: float64(currentPeriod.TotalRequests),
Trend: reqTrend, Trend: reqTrend,
TrendIsGrowth: reqTrend >= 0, TrendIsGrowth: reqTrendIsGrowth,
}, },
ErrorRate: models.StatCard{ ErrorRate: models.StatCard{
Value: currentErrorRate, Value: currentErrorRate,
Trend: errorRateTrend, Trend: errorRateTrend,
TrendIsGrowth: errorRateTrend < 0, // 错误率下降是好事 TrendIsGrowth: errorRateTrendIsGrowth,
}, },
} }
response.Success(c, stats) response.Success(c, stats)
} }
// Chart Get dashboard chart data
// 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]
func (s *Server) Chart(c *gin.Context) { func (s *Server) Chart(c *gin.Context) {
groupID := c.Query("groupId") groupID := c.Query("groupId")
@@ -95,7 +111,7 @@ func (s *Server) Chart(c *gin.Context) {
twentyFourHoursAgo := now.Add(-24 * time.Hour) twentyFourHoursAgo := now.Add(-24 * time.Hour)
var hourlyStats []models.GroupHourlyStat var hourlyStats []models.GroupHourlyStat
query := s.DB.Where("time >= ?", twentyFourHoursAgo) query := s.DB.Where("time >= ? AND time < ?", twentyFourHoursAgo, now)
if groupID != "" { if groupID != "" {
query = query.Where("group_id = ?", groupID) query = query.Where("group_id = ?", groupID)
} }
@@ -149,7 +165,6 @@ func (s *Server) Chart(c *gin.Context) {
response.Success(c, chartData) response.Success(c, chartData)
} }
type hourlyStatResult struct { type hourlyStatResult struct {
TotalRequests int64 TotalRequests int64
TotalFailures int64 TotalFailures int64

View File

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