feat: dashboard 初版
This commit is contained in:
@@ -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]
|
constructor, ok := channelRegistry[group.ChannelType]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
@@ -2,46 +2,164 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
app_errors "gpt-load/internal/errors"
|
||||||
"gpt-load/internal/models"
|
"gpt-load/internal/models"
|
||||||
"gpt-load/internal/response"
|
"gpt-load/internal/response"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetDashboardStats godoc
|
// Stats godoc
|
||||||
// @Summary Get dashboard statistics
|
// @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
|
// @Tags Dashboard
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} response.Response{data=models.DashboardStatsResponse}
|
||||||
// @Router /api/dashboard/stats [get]
|
// @Router /dashboard/stats [get]
|
||||||
func (s *Server) Stats(c *gin.Context) {
|
func (s *Server) Stats(c *gin.Context) {
|
||||||
var totalRequests, successRequests int64
|
var activeKeys, invalidKeys, groupCount int64
|
||||||
var groupStats []models.GroupRequestStat
|
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
|
now := time.Now()
|
||||||
s.DB.Model(&models.APIKey{}).Select("SUM(request_count)").Row().Scan(&totalRequests)
|
twentyFourHoursAgo := now.Add(-24 * time.Hour)
|
||||||
s.DB.Model(&models.APIKey{}).Select("SUM(request_count) - SUM(failure_count)").Row().Scan(&successRequests)
|
fortyEightHoursAgo := now.Add(-48 * time.Hour)
|
||||||
|
|
||||||
// 2. Get request counts per group
|
currentPeriod, err := s.getHourlyStats(twentyFourHoursAgo, now)
|
||||||
s.DB.Table("api_keys").
|
if err != nil {
|
||||||
Select("groups.display_name as display_name, SUM(api_keys.request_count) as request_count").
|
response.Error(c, app_errors.NewAPIError(app_errors.ErrDatabase, "failed to get current period stats"))
|
||||||
Joins("join groups on groups.id = api_keys.group_id").
|
return
|
||||||
Group("groups.id, groups.display_name").
|
}
|
||||||
Order("request_count DESC").
|
previousPeriod, err := s.getHourlyStats(fortyEightHoursAgo, twentyFourHoursAgo)
|
||||||
Scan(&groupStats)
|
if err != nil {
|
||||||
|
response.Error(c, app_errors.NewAPIError(app_errors.ErrDatabase, "failed to get previous period stats"))
|
||||||
// 3. Calculate success rate
|
return
|
||||||
var successRate float64
|
|
||||||
if totalRequests > 0 {
|
|
||||||
successRate = float64(successRequests) / float64(totalRequests) * 100
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stats := models.DashboardStats{
|
reqTrend := 0.0
|
||||||
TotalRequests: totalRequests,
|
if previousPeriod.TotalRequests > 0 {
|
||||||
SuccessRequests: successRequests,
|
reqTrend = (float64(currentPeriod.TotalRequests-previousPeriod.TotalRequests) / float64(previousPeriod.TotalRequests)) * 100
|
||||||
SuccessRate: successRate,
|
}
|
||||||
GroupStats: groupStats,
|
|
||||||
|
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)
|
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
|
||||||
|
}
|
||||||
|
@@ -711,3 +711,21 @@ func (s *Server) GetGroupStats(c *gin.Context) {
|
|||||||
|
|
||||||
response.Success(c, resp)
|
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)
|
||||||
|
}
|
||||||
|
@@ -85,18 +85,34 @@ type RequestLog struct {
|
|||||||
Retries int `gorm:"not null" json:"retries"`
|
Retries int `gorm:"not null" json:"retries"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GroupRequestStat 用于表示每个分组的请求统计
|
// StatCard 用于仪表盘的单个统计卡片数据
|
||||||
type GroupRequestStat struct {
|
type StatCard struct {
|
||||||
DisplayName string `json:"display_name"`
|
Value float64 `json:"value"`
|
||||||
RequestCount int64 `json:"request_count"`
|
SubValue int64 `json:"sub_value,omitempty"`
|
||||||
|
SubValueTip string `json:"sub_value_tip,omitempty"`
|
||||||
|
Trend float64 `json:"trend"`
|
||||||
|
TrendIsGrowth bool `json:"trend_is_growth"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DashboardStats 用于仪表盘的统计数据
|
// DashboardStatsResponse 用于仪表盘基础统计的API响应
|
||||||
type DashboardStats struct {
|
type DashboardStatsResponse struct {
|
||||||
TotalRequests int64 `json:"total_requests"`
|
KeyCount StatCard `json:"key_count"`
|
||||||
SuccessRequests int64 `json:"success_requests"`
|
GroupCount StatCard `json:"group_count"`
|
||||||
SuccessRate float64 `json:"success_rate"`
|
RequestCount StatCard `json:"request_count"`
|
||||||
GroupStats []GroupRequestStat `json:"group_stats"`
|
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 表,用于存储每个分组每小时的请求统计
|
// GroupHourlyStat 对应 group_hourly_stats 表,用于存储每个分组每小时的请求统计
|
||||||
|
@@ -110,6 +110,7 @@ func registerProtectedAPIRoutes(api *gin.RouterGroup, serverHandler *handler.Ser
|
|||||||
{
|
{
|
||||||
groups.POST("", serverHandler.CreateGroup)
|
groups.POST("", serverHandler.CreateGroup)
|
||||||
groups.GET("", serverHandler.ListGroups)
|
groups.GET("", serverHandler.ListGroups)
|
||||||
|
groups.GET("/list", serverHandler.List)
|
||||||
groups.GET("/config-options", serverHandler.GetGroupConfigOptions)
|
groups.GET("/config-options", serverHandler.GetGroupConfigOptions)
|
||||||
groups.PUT("/:id", serverHandler.UpdateGroup)
|
groups.PUT("/:id", serverHandler.UpdateGroup)
|
||||||
groups.DELETE("/:id", serverHandler.DeleteGroup)
|
groups.DELETE("/:id", serverHandler.DeleteGroup)
|
||||||
@@ -136,6 +137,7 @@ func registerProtectedAPIRoutes(api *gin.RouterGroup, serverHandler *handler.Ser
|
|||||||
dashboard := api.Group("/dashboard")
|
dashboard := api.Group("/dashboard")
|
||||||
{
|
{
|
||||||
dashboard.GET("/stats", serverHandler.Stats)
|
dashboard.GET("/stats", serverHandler.Stats)
|
||||||
|
dashboard.GET("/chart", serverHandler.Chart)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 日志
|
// 日志
|
||||||
|
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">
|
<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";
|
import { onMounted, ref } from "vue";
|
||||||
|
|
||||||
// 模拟数据
|
// 统计数据
|
||||||
const stats = ref([
|
const stats = ref<DashboardStatsResponse | null>(null);
|
||||||
{
|
const loading = ref(true);
|
||||||
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 animatedValues = ref<Record<string, number>>({});
|
const animatedValues = ref<Record<string, number>>({});
|
||||||
|
|
||||||
onMounted(() => {
|
// 格式化数值显示
|
||||||
// 动画效果
|
const formatValue = (value: number, type: "count" | "rate" = "count"): string => {
|
||||||
stats.value.forEach((stat, index) => {
|
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(() => {
|
setTimeout(() => {
|
||||||
animatedValues.value[stat.title] = 1;
|
animatedValues.value = {
|
||||||
}, index * 150);
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -54,32 +61,124 @@ onMounted(() => {
|
|||||||
<div class="stats-container">
|
<div class="stats-container">
|
||||||
<n-space vertical size="medium">
|
<n-space vertical size="medium">
|
||||||
<n-grid :cols="4" :x-gap="20" :y-gap="20" responsive="screen">
|
<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
|
<n-grid-item span="1">
|
||||||
:bordered="false"
|
<n-card :bordered="false" class="stat-card" style="animation-delay: 0s">
|
||||||
class="stat-card"
|
|
||||||
:style="{ animationDelay: `${index * 0.05}s` }"
|
|
||||||
>
|
|
||||||
<div class="stat-header">
|
<div class="stat-header">
|
||||||
<div class="stat-icon" :style="{ background: stat.color }">
|
<div class="stat-icon key-icon">🔑</div>
|
||||||
{{ stat.icon }}
|
<n-tooltip v-if="stats?.key_count.sub_value" trigger="hover">
|
||||||
</div>
|
<template #trigger>
|
||||||
<n-tag :type="stat.trendUp ? 'success' : 'error'" size="small" class="stat-trend">
|
<n-tag type="error" size="small" class="stat-trend">
|
||||||
{{ stat.trend }}
|
{{ stats.key_count.sub_value }}
|
||||||
</n-tag>
|
</n-tag>
|
||||||
|
</template>
|
||||||
|
{{ stats.key_count.sub_value_tip }}
|
||||||
|
</n-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stat-content">
|
<div class="stat-content">
|
||||||
<div class="stat-value">{{ stat.value }}</div>
|
<div class="stat-value">
|
||||||
<div class="stat-title">{{ stat.title }}</div>
|
{{ stats ? formatValue(stats.key_count.value) : "--" }}
|
||||||
|
</div>
|
||||||
|
<div class="stat-title">秘钥数量</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stat-bar">
|
<div class="stat-bar">
|
||||||
<div
|
<div
|
||||||
class="stat-bar-fill"
|
class="stat-bar-fill key-bar"
|
||||||
:style="{
|
:style="{
|
||||||
background: stat.color,
|
width: `${animatedValues.key_count * 100}%`,
|
||||||
width: `${animatedValues[stat.title] * 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>
|
</div>
|
||||||
@@ -130,6 +229,22 @@ onMounted(() => {
|
|||||||
box-shadow: var(--shadow-md);
|
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 {
|
.stat-trend {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
@@ -177,6 +292,22 @@ onMounted(() => {
|
|||||||
transition-delay: 0.2s;
|
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 {
|
@keyframes slideInUp {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
@@ -1,44 +1,163 @@
|
|||||||
<script setup lang="ts">
|
<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({
|
const chartData = ref<ChartData | null>(null);
|
||||||
labels: ["00:00", "04:00", "08:00", "12:00", "16:00", "20:00", "24:00"],
|
const selectedGroup = ref<number | null>(null);
|
||||||
datasets: [
|
const loading = ref(true);
|
||||||
{
|
const animationProgress = ref(0);
|
||||||
label: "请求数量",
|
const hoveredPoint = ref<{
|
||||||
data: [120, 150, 300, 450, 380, 280, 200],
|
datasetIndex: number;
|
||||||
color: "#667eea",
|
pointIndex: number;
|
||||||
},
|
x: number;
|
||||||
{
|
y: number;
|
||||||
label: "响应时间",
|
} | null>(null);
|
||||||
data: [200, 180, 250, 300, 220, 190, 160],
|
const tooltipData = ref<{
|
||||||
color: "#f093fb",
|
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>();
|
// 生成Y轴刻度
|
||||||
const animationProgress = ref(0);
|
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 points = data.map((value, index) => {
|
||||||
const x = (index / (data.length - 1)) * 380 + 10;
|
const x = getXPosition(index);
|
||||||
const y = 200 - (value / 500) * 180 - 10;
|
const y = getYPosition(value);
|
||||||
return `${x},${y}`;
|
return `${x},${y}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
return `M ${points.join(" L ")}`;
|
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;
|
let start = 0;
|
||||||
const animate = (timestamp: number) => {
|
const animate = (timestamp: number) => {
|
||||||
if (!start) {
|
if (!start) {
|
||||||
start = timestamp;
|
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;
|
animationProgress.value = progress;
|
||||||
|
|
||||||
if (progress < 1) {
|
if (progress < 1) {
|
||||||
@@ -46,206 +165,521 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
requestAnimationFrame(animate);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="chart-container">
|
<div class="chart-container">
|
||||||
<n-card class="chart-card modern-card" :bordered="false">
|
<div class="chart-header">
|
||||||
<template #header>
|
<h3 class="chart-title">24小时请求趋势</h3>
|
||||||
<div class="chart-header">
|
<n-select
|
||||||
<h3 class="chart-title">性能监控</h3>
|
v-model:value="selectedGroup"
|
||||||
<p class="chart-subtitle">实时系统性能指标</p>
|
: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>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
|
|
||||||
<div ref="chartContainer" class="chart-content">
|
<div class="chart-wrapper">
|
||||||
<div class="chart-legend">
|
<svg
|
||||||
<div v-for="dataset in chartData.datasets" :key="dataset.label" class="legend-item">
|
ref="chartSvg"
|
||||||
<div class="legend-color" :style="{ backgroundColor: dataset.color }" />
|
:width="chartWidth"
|
||||||
<span class="legend-label">{{ dataset.label }}</span>
|
:height="chartHeight"
|
||||||
</div>
|
class="chart-svg"
|
||||||
</div>
|
@mousemove="handleMouseMove"
|
||||||
|
@mouseleave="hideTooltip"
|
||||||
<div class="chart-area">
|
>
|
||||||
<div class="chart-grid">
|
<!-- 背景网格 -->
|
||||||
<div
|
<defs>
|
||||||
v-for="(label, index) in chartData.labels"
|
<pattern id="grid" width="40" height="30" patternUnits="userSpaceOnUse">
|
||||||
: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">
|
|
||||||
<path
|
<path
|
||||||
:d="generatePath(dataset.data)"
|
d="M 40 0 L 0 0 0 30"
|
||||||
:stroke="dataset.color"
|
|
||||||
stroke-width="3"
|
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke-linecap="round"
|
stroke="#f0f0f0"
|
||||||
stroke-linejoin="round"
|
stroke-width="1"
|
||||||
class="chart-line"
|
opacity="0.3"
|
||||||
:style="{
|
|
||||||
strokeDasharray: '1000',
|
|
||||||
strokeDashoffset: `${1000 * (1 - animationProgress)}`,
|
|
||||||
}"
|
|
||||||
/>
|
/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||||
|
|
||||||
<!-- 数据点 -->
|
<!-- Y轴刻度线和标签 -->
|
||||||
<g v-for="(value, index) in dataset.data" :key="index">
|
<g class="y-axis">
|
||||||
<circle
|
<line
|
||||||
:cx="(index / (dataset.data.length - 1)) * 380 + 10"
|
:x1="padding.left"
|
||||||
:cy="200 - (value / 500) * 180 - 10"
|
:y1="padding.top"
|
||||||
:r="animationProgress > index / dataset.data.length ? 4 : 0"
|
:x2="padding.left"
|
||||||
:fill="dataset.color"
|
:y2="chartHeight - padding.bottom"
|
||||||
class="chart-point"
|
stroke="#e0e0e0"
|
||||||
/>
|
stroke-width="2"
|
||||||
</g>
|
/>
|
||||||
|
<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>
|
</g>
|
||||||
</svg>
|
</g>
|
||||||
|
|
||||||
<div class="chart-labels">
|
<!-- X轴刻度线和标签 -->
|
||||||
<div
|
<g class="x-axis">
|
||||||
v-for="(label, index) in chartData.labels"
|
<line
|
||||||
:key="label"
|
:x1="padding.left"
|
||||||
class="chart-label"
|
:y1="chartHeight - padding.bottom"
|
||||||
:style="{ left: `${(index / (chartData.labels.length - 1)) * 100}%` }"
|
:x2="chartWidth - padding.right"
|
||||||
>
|
:y2="chartHeight - padding.bottom"
|
||||||
{{ label }}
|
stroke="#e0e0e0"
|
||||||
</div>
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</n-card>
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="chart-loading">
|
||||||
|
<n-spin size="large" />
|
||||||
|
<p>加载中...</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.chart-container {
|
.chart-container {
|
||||||
width: 100%;
|
padding: 20px;
|
||||||
}
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border-radius: 16px;
|
||||||
.chart-card {
|
box-shadow: 0 8px 32px rgba(31, 38, 135, 0.37);
|
||||||
background: rgba(255, 255, 255, 0.98);
|
backdrop-filter: blur(4px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-header {
|
.chart-header {
|
||||||
text-align: center;
|
display: flex;
|
||||||
margin-bottom: 8px;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-title {
|
.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;
|
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 {
|
.chart-content {
|
||||||
position: relative;
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-legend {
|
.chart-legend {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 20px;
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-item {
|
.legend-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-color {
|
.legend-color {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
border-radius: 4px;
|
border-radius: 50%;
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-label {
|
.legend-label {
|
||||||
font-size: 0.9rem;
|
font-size: 14px;
|
||||||
color: #374151;
|
color: #666;
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-area {
|
.chart-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 240px;
|
display: flex;
|
||||||
background: linear-gradient(180deg, rgba(102, 126, 234, 0.02) 0%, rgba(102, 126, 234, 0.08) 100%);
|
justify-content: center;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-svg {
|
.chart-svg {
|
||||||
width: 100%;
|
background: white;
|
||||||
height: 200px;
|
border-radius: 8px;
|
||||||
position: relative;
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
z-index: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-line {
|
.axis-label {
|
||||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1));
|
fill: #666;
|
||||||
transition: stroke-dashoffset 0.2s ease-out;
|
font-size: 12px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-point {
|
.line-path {
|
||||||
transition: r 0.2s ease;
|
transition: all 0.3s ease;
|
||||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
r: 6;
|
||||||
|
filter: drop-shadow(0 0 8px rgba(0, 0, 0, 0.3));
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-labels {
|
.chart-tooltip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
background: rgba(0, 0, 0, 0.8);
|
||||||
left: 0;
|
color: white;
|
||||||
right: 0;
|
padding: 8px 12px;
|
||||||
height: 40px;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-label {
|
.tooltip-color {
|
||||||
position: absolute;
|
width: 8px;
|
||||||
font-size: 0.8rem;
|
height: 8px;
|
||||||
color: #64748b;
|
border-radius: 50%;
|
||||||
font-weight: 500;
|
}
|
||||||
transform: translateX(-50%);
|
|
||||||
background: rgba(255, 255, 255, 0.9);
|
.chart-loading {
|
||||||
padding: 4px 8px;
|
display: flex;
|
||||||
border-radius: 4px;
|
flex-direction: column;
|
||||||
backdrop-filter: blur(4px);
|
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>
|
</style>
|
||||||
|
@@ -143,3 +143,33 @@ export interface GroupRequestStat {
|
|||||||
display_name: string;
|
display_name: string;
|
||||||
request_count: number;
|
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