From e07ef88e2aef7a0f7caad170f5f45d78896ce391 Mon Sep 17 00:00:00 2001 From: tbphp Date: Sat, 12 Jul 2025 20:24:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20dashboard=20=E5=88=9D=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/channel/factory.go | 2 +- internal/handler/dashboard_handler.go | 170 +++++- internal/handler/group_handler.go | 18 + internal/models/types.go | 36 +- internal/router/router.go | 2 + web/src/api/dashboard.ts | 26 + web/src/components/BaseInfoCard.vue | 251 ++++++--- web/src/components/LineChart.vue | 744 ++++++++++++++++++++------ web/src/types/models.ts | 30 ++ 9 files changed, 1027 insertions(+), 252 deletions(-) create mode 100644 web/src/api/dashboard.ts diff --git a/internal/channel/factory.go b/internal/channel/factory.go index 322df55..ef8fd53 100644 --- a/internal/channel/factory.go +++ b/internal/channel/factory.go @@ -66,7 +66,7 @@ func (f *Factory) GetChannel(group *models.Group) (ChannelProxy, error) { } } - logrus.Infof("Creating new channel for group %d with type '%s'", group.ID, group.ChannelType) + logrus.Debugf("Creating new channel for group %d with type '%s'", group.ID, group.ChannelType) constructor, ok := channelRegistry[group.ChannelType] if !ok { diff --git a/internal/handler/dashboard_handler.go b/internal/handler/dashboard_handler.go index ea6002e..2081078 100644 --- a/internal/handler/dashboard_handler.go +++ b/internal/handler/dashboard_handler.go @@ -2,46 +2,164 @@ package handler import ( "github.com/gin-gonic/gin" + app_errors "gpt-load/internal/errors" "gpt-load/internal/models" "gpt-load/internal/response" + "time" ) -// GetDashboardStats godoc +// Stats godoc // @Summary Get dashboard statistics -// @Description Get statistics for the dashboard, including key counts and request metrics. +// @Description Get statistics for the dashboard cards // @Tags Dashboard // @Accept json // @Produce json -// @Success 200 {object} map[string]any -// @Router /api/dashboard/stats [get] +// @Success 200 {object} response.Response{data=models.DashboardStatsResponse} +// @Router /dashboard/stats [get] func (s *Server) Stats(c *gin.Context) { - var totalRequests, successRequests int64 - var groupStats []models.GroupRequestStat + var activeKeys, invalidKeys, groupCount int64 + s.DB.Model(&models.APIKey{}).Where("status = ?", models.KeyStatusActive).Count(&activeKeys) + s.DB.Model(&models.APIKey{}).Where("status = ?", models.KeyStatusInvalid).Count(&invalidKeys) + s.DB.Model(&models.Group{}).Count(&groupCount) - // 1. Get total and successful requests from the api_keys table - s.DB.Model(&models.APIKey{}).Select("SUM(request_count)").Row().Scan(&totalRequests) - s.DB.Model(&models.APIKey{}).Select("SUM(request_count) - SUM(failure_count)").Row().Scan(&successRequests) + now := time.Now() + twentyFourHoursAgo := now.Add(-24 * time.Hour) + fortyEightHoursAgo := now.Add(-48 * time.Hour) - // 2. Get request counts per group - s.DB.Table("api_keys"). - Select("groups.display_name as display_name, SUM(api_keys.request_count) as request_count"). - Joins("join groups on groups.id = api_keys.group_id"). - Group("groups.id, groups.display_name"). - Order("request_count DESC"). - Scan(&groupStats) - - // 3. Calculate success rate - var successRate float64 - if totalRequests > 0 { - successRate = float64(successRequests) / float64(totalRequests) * 100 + currentPeriod, err := s.getHourlyStats(twentyFourHoursAgo, now) + if err != nil { + response.Error(c, app_errors.NewAPIError(app_errors.ErrDatabase, "failed to get current period stats")) + return + } + previousPeriod, err := s.getHourlyStats(fortyEightHoursAgo, twentyFourHoursAgo) + if err != nil { + response.Error(c, app_errors.NewAPIError(app_errors.ErrDatabase, "failed to get previous period stats")) + return } - stats := models.DashboardStats{ - TotalRequests: totalRequests, - SuccessRequests: successRequests, - SuccessRate: successRate, - GroupStats: groupStats, + reqTrend := 0.0 + if previousPeriod.TotalRequests > 0 { + reqTrend = (float64(currentPeriod.TotalRequests-previousPeriod.TotalRequests) / float64(previousPeriod.TotalRequests)) * 100 + } + + currentErrorRate := 0.0 + if currentPeriod.TotalRequests > 0 { + currentErrorRate = (float64(currentPeriod.TotalFailures) / float64(currentPeriod.TotalRequests)) * 100 + } + + previousErrorRate := 0.0 + if previousPeriod.TotalRequests > 0 { + previousErrorRate = (float64(previousPeriod.TotalFailures) / float64(previousPeriod.TotalRequests)) * 100 + } + + errorRateTrend := currentErrorRate - previousErrorRate + + stats := models.DashboardStatsResponse{ + KeyCount: models.StatCard{ + Value: float64(activeKeys), + SubValue: invalidKeys, + SubValueTip: "无效秘钥数量", + }, + GroupCount: models.StatCard{ + Value: float64(groupCount), + }, + RequestCount: models.StatCard{ + Value: float64(currentPeriod.TotalRequests), + Trend: reqTrend, + TrendIsGrowth: reqTrend >= 0, + }, + ErrorRate: models.StatCard{ + Value: currentErrorRate, + Trend: errorRateTrend, + TrendIsGrowth: errorRateTrend < 0, // 错误率下降是好事 + }, } 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] +func (s *Server) Chart(c *gin.Context) { + groupID := c.Query("groupId") + + now := time.Now() + twentyFourHoursAgo := now.Add(-24 * time.Hour) + + var hourlyStats []models.GroupHourlyStat + query := s.DB.Where("time >= ?", twentyFourHoursAgo) + if groupID != "" { + query = query.Where("group_id = ?", groupID) + } + if err := query.Order("time asc").Find(&hourlyStats).Error; err != nil { + response.Error(c, app_errors.NewAPIError(app_errors.ErrDatabase, "failed to get chart data")) + return + } + + statsByHour := make(map[time.Time]map[string]int64) + for _, stat := range hourlyStats { + hour := stat.Time.Truncate(time.Hour) + if _, ok := statsByHour[hour]; !ok { + statsByHour[hour] = make(map[string]int64) + } + statsByHour[hour]["success"] += stat.SuccessCount + statsByHour[hour]["failure"] += stat.FailureCount + } + + var labels []string + var successData, failureData []int64 + + for i := 0; i < 24; i++ { + hour := twentyFourHoursAgo.Add(time.Duration(i) * time.Hour).Truncate(time.Hour) + labels = append(labels, hour.Format("15:04")) + + if data, ok := statsByHour[hour]; ok { + successData = append(successData, data["success"]) + failureData = append(failureData, data["failure"]) + } else { + successData = append(successData, 0) + failureData = append(failureData, 0) + } + } + + chartData := models.ChartData{ + Labels: labels, + Datasets: []models.ChartDataset{ + { + Label: "成功请求", + Data: successData, + Color: "rgba(10, 200, 110, 1)", + }, + { + Label: "失败请求", + Data: failureData, + Color: "rgba(255, 70, 70, 1)", + }, + }, + } + + response.Success(c, chartData) +} + + +type hourlyStatResult struct { + TotalRequests int64 + TotalFailures int64 +} + +func (s *Server) getHourlyStats(startTime, endTime time.Time) (hourlyStatResult, error) { + var result hourlyStatResult + err := s.DB.Model(&models.GroupHourlyStat{}). + Select("sum(success_count) + sum(failure_count) as total_requests, sum(failure_count) as total_failures"). + Where("time >= ? AND time < ?", startTime, endTime). + Scan(&result).Error + return result, err +} diff --git a/internal/handler/group_handler.go b/internal/handler/group_handler.go index 4d891d3..ae8045a 100644 --- a/internal/handler/group_handler.go +++ b/internal/handler/group_handler.go @@ -711,3 +711,21 @@ func (s *Server) GetGroupStats(c *gin.Context) { response.Success(c, resp) } + + +// List godoc +// @Summary List all groups for selection +// @Description Get a list of all groups with their ID and display name +// @Tags Groups +// @Accept json +// @Produce json +// @Success 200 {object} response.Response{data=[]models.Group} +// @Router /groups/list [get] +func (s *Server) List(c *gin.Context) { + var groups []models.Group + if err := s.DB.Select("id, display_name").Find(&groups).Error; err != nil { + response.Error(c, app_errors.NewAPIError(app_errors.ErrDatabase, "无法获取分组列表")) + return + } + response.Success(c, groups) +} diff --git a/internal/models/types.go b/internal/models/types.go index abfc5b3..35b7221 100644 --- a/internal/models/types.go +++ b/internal/models/types.go @@ -85,18 +85,34 @@ type RequestLog struct { Retries int `gorm:"not null" json:"retries"` } -// GroupRequestStat 用于表示每个分组的请求统计 -type GroupRequestStat struct { - DisplayName string `json:"display_name"` - RequestCount int64 `json:"request_count"` +// StatCard 用于仪表盘的单个统计卡片数据 +type StatCard struct { + Value float64 `json:"value"` + SubValue int64 `json:"sub_value,omitempty"` + SubValueTip string `json:"sub_value_tip,omitempty"` + Trend float64 `json:"trend"` + TrendIsGrowth bool `json:"trend_is_growth"` } -// DashboardStats 用于仪表盘的统计数据 -type DashboardStats struct { - TotalRequests int64 `json:"total_requests"` - SuccessRequests int64 `json:"success_requests"` - SuccessRate float64 `json:"success_rate"` - GroupStats []GroupRequestStat `json:"group_stats"` +// DashboardStatsResponse 用于仪表盘基础统计的API响应 +type DashboardStatsResponse struct { + KeyCount StatCard `json:"key_count"` + GroupCount StatCard `json:"group_count"` + RequestCount StatCard `json:"request_count"` + ErrorRate StatCard `json:"error_rate"` +} + +// ChartDataset 用于图表的数据集 +type ChartDataset struct { + Label string `json:"label"` + Data []int64 `json:"data"` + Color string `json:"color"` +} + +// ChartData 用于图表的API响应 +type ChartData struct { + Labels []string `json:"labels"` + Datasets []ChartDataset `json:"datasets"` } // GroupHourlyStat 对应 group_hourly_stats 表,用于存储每个分组每小时的请求统计 diff --git a/internal/router/router.go b/internal/router/router.go index ed66c2b..822fbae 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -110,6 +110,7 @@ func registerProtectedAPIRoutes(api *gin.RouterGroup, serverHandler *handler.Ser { groups.POST("", serverHandler.CreateGroup) groups.GET("", serverHandler.ListGroups) + groups.GET("/list", serverHandler.List) groups.GET("/config-options", serverHandler.GetGroupConfigOptions) groups.PUT("/:id", serverHandler.UpdateGroup) groups.DELETE("/:id", serverHandler.DeleteGroup) @@ -136,6 +137,7 @@ func registerProtectedAPIRoutes(api *gin.RouterGroup, serverHandler *handler.Ser dashboard := api.Group("/dashboard") { dashboard.GET("/stats", serverHandler.Stats) + dashboard.GET("/chart", serverHandler.Chart) } // 日志 diff --git a/web/src/api/dashboard.ts b/web/src/api/dashboard.ts new file mode 100644 index 0000000..195991a --- /dev/null +++ b/web/src/api/dashboard.ts @@ -0,0 +1,26 @@ +import type { ChartData, DashboardStatsResponse, Group } from "@/types/models"; +import http from "@/utils/http"; + +/** + * 获取仪表盘基础统计数据 + */ +export const getDashboardStats = () => { + return http.get("/dashboard/stats"); +}; + +/** + * 获取仪表盘图表数据 + * @param groupId 可选的分组ID + */ +export const getDashboardChart = (groupId?: number) => { + return http.get("/dashboard/chart", { + params: groupId ? { groupId } : {}, + }); +}; + +/** + * 获取用于筛选的分组列表 + */ +export const getGroupList = () => { + return http.get("/groups/list"); +}; diff --git a/web/src/components/BaseInfoCard.vue b/web/src/components/BaseInfoCard.vue index f81b2c1..6d4c0e0 100644 --- a/web/src/components/BaseInfoCard.vue +++ b/web/src/components/BaseInfoCard.vue @@ -1,52 +1,59 @@ @@ -54,32 +61,124 @@ onMounted(() => {
- - + + +
-
- {{ stat.icon }} -
- - {{ stat.trend }} - +
🔑
+ + + {{ stats.key_count.sub_value_tip }} +
-
{{ stat.value }}
-
{{ stat.title }}
+
+ {{ stats ? formatValue(stats.key_count.value) : "--" }} +
+
秘钥数量
+
+ + + + + + +
+
📁
+
+ +
+
+ {{ stats ? formatValue(stats.group_count.value) : "--" }} +
+
分组数量
+
+ +
+
+
+ + + + + + +
+
📈
+ + {{ stats ? formatTrend(stats.request_count.trend) : "--" }} + +
+ +
+
+ {{ stats ? formatValue(stats.request_count.value) : "--" }} +
+
24小时请求
+
+ +
+
+
+ + + + + + +
+
🛡️
+ + {{ stats ? formatTrend(stats.error_rate.trend) : "--" }} + +
+ +
+
+ {{ stats ? formatValue(stats.error_rate.value, "rate") : "--" }} +
+
24小时错误率
+
+ +
+
@@ -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; diff --git a/web/src/components/LineChart.vue b/web/src/components/LineChart.vue index 32c4aef..ba7b290 100644 --- a/web/src/components/LineChart.vue +++ b/web/src/components/LineChart.vue @@ -1,44 +1,163 @@ diff --git a/web/src/types/models.ts b/web/src/types/models.ts index 59b59cd..13f4fc6 100644 --- a/web/src/types/models.ts +++ b/web/src/types/models.ts @@ -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[]; +}