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]
|
||||
if !ok {
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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)
|
||||
}
|
||||
|
@@ -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 表,用于存储每个分组每小时的请求统计
|
||||
|
@@ -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)
|
||||
}
|
||||
|
||||
// 日志
|
||||
|
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">
|
||||
import { NCard, NGrid, NGridItem, NSpace, NTag } from "naive-ui";
|
||||
import { getDashboardStats } from "@/api/dashboard";
|
||||
import type { DashboardStatsResponse } from "@/types/models";
|
||||
import { NCard, NGrid, NGridItem, NSpace, NTag, NTooltip } from "naive-ui";
|
||||
import { onMounted, ref } from "vue";
|
||||
|
||||
// 模拟数据
|
||||
const stats = ref([
|
||||
{
|
||||
title: "总请求数",
|
||||
value: "125,842",
|
||||
icon: "📈",
|
||||
color: "var(--primary-gradient)",
|
||||
trend: "+12.5%",
|
||||
trendUp: true,
|
||||
},
|
||||
{
|
||||
title: "活跃连接",
|
||||
value: "1,234",
|
||||
icon: "🔗",
|
||||
color: "var(--success-gradient)",
|
||||
trend: "+5.2%",
|
||||
trendUp: true,
|
||||
},
|
||||
{
|
||||
title: "响应时间",
|
||||
value: "245ms",
|
||||
icon: "⚡",
|
||||
color: "var(--warning-gradient)",
|
||||
trend: "-8.1%",
|
||||
trendUp: false,
|
||||
},
|
||||
{
|
||||
title: "错误率",
|
||||
value: "0.12%",
|
||||
icon: "🛡️",
|
||||
color: "var(--secondary-gradient)",
|
||||
trend: "-2.3%",
|
||||
trendUp: false,
|
||||
},
|
||||
]);
|
||||
|
||||
// 统计数据
|
||||
const stats = ref<DashboardStatsResponse | null>(null);
|
||||
const loading = ref(true);
|
||||
const animatedValues = ref<Record<string, number>>({});
|
||||
|
||||
onMounted(() => {
|
||||
// 动画效果
|
||||
stats.value.forEach((stat, index) => {
|
||||
// 格式化数值显示
|
||||
const formatValue = (value: number, type: "count" | "rate" = "count"): string => {
|
||||
if (type === "rate") {
|
||||
return `${value.toFixed(2)}%`;
|
||||
}
|
||||
if (value >= 10000) {
|
||||
return `${(value / 10000).toFixed(1)}w`;
|
||||
}
|
||||
if (value >= 1000) {
|
||||
return `${(value / 1000).toFixed(1)}k`;
|
||||
}
|
||||
return value.toString();
|
||||
};
|
||||
|
||||
// 格式化趋势显示
|
||||
const formatTrend = (trend: number): string => {
|
||||
const sign = trend >= 0 ? "+" : "";
|
||||
return `${sign}${trend.toFixed(1)}%`;
|
||||
};
|
||||
|
||||
// 获取统计数据
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
const response = await getDashboardStats();
|
||||
stats.value = response.data;
|
||||
|
||||
// 添加动画效果
|
||||
setTimeout(() => {
|
||||
animatedValues.value[stat.title] = 1;
|
||||
}, index * 150);
|
||||
});
|
||||
animatedValues.value = {
|
||||
key_count: 1,
|
||||
group_count: 1,
|
||||
request_count: 1,
|
||||
error_rate: 1,
|
||||
};
|
||||
}, 150);
|
||||
} catch (error) {
|
||||
console.error("获取统计数据失败:", error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchStats();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -54,32 +61,124 @@ onMounted(() => {
|
||||
<div class="stats-container">
|
||||
<n-space vertical size="medium">
|
||||
<n-grid :cols="4" :x-gap="20" :y-gap="20" responsive="screen">
|
||||
<n-grid-item v-for="(stat, index) in stats" :key="stat.title" span="1">
|
||||
<n-card
|
||||
:bordered="false"
|
||||
class="stat-card"
|
||||
:style="{ animationDelay: `${index * 0.05}s` }"
|
||||
>
|
||||
<!-- 秘钥数量 -->
|
||||
<n-grid-item span="1">
|
||||
<n-card :bordered="false" class="stat-card" style="animation-delay: 0s">
|
||||
<div class="stat-header">
|
||||
<div class="stat-icon" :style="{ background: stat.color }">
|
||||
{{ stat.icon }}
|
||||
</div>
|
||||
<n-tag :type="stat.trendUp ? 'success' : 'error'" size="small" class="stat-trend">
|
||||
{{ stat.trend }}
|
||||
</n-tag>
|
||||
<div class="stat-icon key-icon">🔑</div>
|
||||
<n-tooltip v-if="stats?.key_count.sub_value" trigger="hover">
|
||||
<template #trigger>
|
||||
<n-tag type="error" size="small" class="stat-trend">
|
||||
{{ stats.key_count.sub_value }}
|
||||
</n-tag>
|
||||
</template>
|
||||
{{ stats.key_count.sub_value_tip }}
|
||||
</n-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stat.value }}</div>
|
||||
<div class="stat-title">{{ stat.title }}</div>
|
||||
<div class="stat-value">
|
||||
{{ stats ? formatValue(stats.key_count.value) : "--" }}
|
||||
</div>
|
||||
<div class="stat-title">秘钥数量</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-bar">
|
||||
<div
|
||||
class="stat-bar-fill"
|
||||
class="stat-bar-fill key-bar"
|
||||
:style="{
|
||||
background: stat.color,
|
||||
width: `${animatedValues[stat.title] * 100}%`,
|
||||
width: `${animatedValues.key_count * 100}%`,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<!-- 分组数量 -->
|
||||
<n-grid-item span="1">
|
||||
<n-card :bordered="false" class="stat-card" style="animation-delay: 0.05s">
|
||||
<div class="stat-header">
|
||||
<div class="stat-icon group-icon">📁</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">
|
||||
{{ stats ? formatValue(stats.group_count.value) : "--" }}
|
||||
</div>
|
||||
<div class="stat-title">分组数量</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-bar">
|
||||
<div
|
||||
class="stat-bar-fill group-bar"
|
||||
:style="{
|
||||
width: `${animatedValues.group_count * 100}%`,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<!-- 24小时请求 -->
|
||||
<n-grid-item span="1">
|
||||
<n-card :bordered="false" class="stat-card" style="animation-delay: 0.1s">
|
||||
<div class="stat-header">
|
||||
<div class="stat-icon request-icon">📈</div>
|
||||
<n-tag
|
||||
v-if="stats?.request_count && stats.request_count.trend !== undefined"
|
||||
:type="stats?.request_count.trend_is_growth ? 'success' : 'error'"
|
||||
size="small"
|
||||
class="stat-trend"
|
||||
>
|
||||
{{ stats ? formatTrend(stats.request_count.trend) : "--" }}
|
||||
</n-tag>
|
||||
</div>
|
||||
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">
|
||||
{{ stats ? formatValue(stats.request_count.value) : "--" }}
|
||||
</div>
|
||||
<div class="stat-title">24小时请求</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-bar">
|
||||
<div
|
||||
class="stat-bar-fill request-bar"
|
||||
:style="{
|
||||
width: `${animatedValues.request_count * 100}%`,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<!-- 24小时错误率 -->
|
||||
<n-grid-item span="1">
|
||||
<n-card :bordered="false" class="stat-card" style="animation-delay: 0.15s">
|
||||
<div class="stat-header">
|
||||
<div class="stat-icon error-icon">🛡️</div>
|
||||
<n-tag
|
||||
v-if="stats?.error_rate.trend !== 0"
|
||||
:type="stats?.error_rate.trend_is_growth ? 'success' : 'error'"
|
||||
size="small"
|
||||
class="stat-trend"
|
||||
>
|
||||
{{ stats ? formatTrend(stats.error_rate.trend) : "--" }}
|
||||
</n-tag>
|
||||
</div>
|
||||
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">
|
||||
{{ stats ? formatValue(stats.error_rate.value, "rate") : "--" }}
|
||||
</div>
|
||||
<div class="stat-title">24小时错误率</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-bar">
|
||||
<div
|
||||
class="stat-bar-fill error-bar"
|
||||
:style="{
|
||||
width: `${animatedValues.error_rate * 100}%`,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
@@ -130,6 +229,22 @@ onMounted(() => {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.key-icon {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.group-icon {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
}
|
||||
|
||||
.request-icon {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
|
||||
}
|
||||
|
||||
.stat-trend {
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -177,6 +292,22 @@ onMounted(() => {
|
||||
transition-delay: 0.2s;
|
||||
}
|
||||
|
||||
.key-bar {
|
||||
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.group-bar {
|
||||
background: linear-gradient(90deg, #f093fb 0%, #f5576c 100%);
|
||||
}
|
||||
|
||||
.request-bar {
|
||||
background: linear-gradient(90deg, #4facfe 0%, #00f2fe 100%);
|
||||
}
|
||||
|
||||
.error-bar {
|
||||
background: linear-gradient(90deg, #43e97b 0%, #38f9d7 100%);
|
||||
}
|
||||
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
@@ -1,44 +1,163 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { getDashboardChart, getGroupList } from "@/api/dashboard";
|
||||
import type { ChartData, ChartDataset } from "@/types/models";
|
||||
import { NSelect, NSpin } from "naive-ui";
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
|
||||
// 模拟图表数据
|
||||
const chartData = ref({
|
||||
labels: ["00:00", "04:00", "08:00", "12:00", "16:00", "20:00", "24:00"],
|
||||
datasets: [
|
||||
{
|
||||
label: "请求数量",
|
||||
data: [120, 150, 300, 450, 380, 280, 200],
|
||||
color: "#667eea",
|
||||
},
|
||||
{
|
||||
label: "响应时间",
|
||||
data: [200, 180, 250, 300, 220, 190, 160],
|
||||
color: "#f093fb",
|
||||
},
|
||||
],
|
||||
// 图表数据
|
||||
const chartData = ref<ChartData | null>(null);
|
||||
const selectedGroup = ref<number | null>(null);
|
||||
const loading = ref(true);
|
||||
const animationProgress = ref(0);
|
||||
const hoveredPoint = ref<{
|
||||
datasetIndex: number;
|
||||
pointIndex: number;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const tooltipData = ref<{
|
||||
time: string;
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
} | null>(null);
|
||||
const tooltipPosition = ref({ x: 0, y: 0 });
|
||||
const chartSvg = ref<SVGElement>();
|
||||
|
||||
// 图表尺寸和边距
|
||||
const chartWidth = 800;
|
||||
const chartHeight = 400;
|
||||
const padding = { top: 40, right: 40, bottom: 60, left: 80 };
|
||||
|
||||
// 格式化分组选项
|
||||
const groupOptions = ref<Array<{ label: string; value: number | null }>>([]);
|
||||
|
||||
// 计算有效的绘图区域
|
||||
const plotWidth = chartWidth - padding.left - padding.right;
|
||||
const plotHeight = chartHeight - padding.top - padding.bottom;
|
||||
|
||||
// 计算数据的最大值和最小值
|
||||
const dataRange = computed(() => {
|
||||
if (!chartData.value) {
|
||||
return { min: 0, max: 100 };
|
||||
}
|
||||
|
||||
const allValues = chartData.value.datasets.flatMap(d => d.data);
|
||||
const max = Math.max(...allValues, 0);
|
||||
const min = Math.min(...allValues, 0);
|
||||
|
||||
// 添加一些padding让图表更好看
|
||||
const paddingValue = (max - min) * 0.1;
|
||||
return {
|
||||
min: Math.max(0, min - paddingValue),
|
||||
max: max + paddingValue,
|
||||
};
|
||||
});
|
||||
|
||||
const chartContainer = ref<HTMLElement>();
|
||||
const animationProgress = ref(0);
|
||||
// 生成Y轴刻度
|
||||
const yTicks = computed(() => {
|
||||
const { min, max } = dataRange.value;
|
||||
const range = max - min;
|
||||
const tickCount = 5;
|
||||
const step = range / (tickCount - 1);
|
||||
|
||||
return Array.from({ length: tickCount }, (_, i) => min + i * step);
|
||||
});
|
||||
|
||||
// 生成可见的X轴标签(避免重叠)
|
||||
const visibleLabels = computed(() => {
|
||||
if (!chartData.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const labels = chartData.value.labels;
|
||||
const maxLabels = 8; // 最多显示8个标签
|
||||
const step = Math.ceil(labels.length / maxLabels);
|
||||
|
||||
return labels.map((label, index) => ({ text: label, index })).filter((_, i) => i % step === 0);
|
||||
});
|
||||
|
||||
// 位置计算函数
|
||||
const getXPosition = (index: number) => {
|
||||
if (!chartData.value) {
|
||||
return 0;
|
||||
}
|
||||
const totalPoints = chartData.value.labels.length;
|
||||
return padding.left + (index / (totalPoints - 1)) * plotWidth;
|
||||
};
|
||||
|
||||
const getYPosition = (value: number) => {
|
||||
const { min, max } = dataRange.value;
|
||||
const ratio = (value - min) / (max - min);
|
||||
return padding.top + (1 - ratio) * plotHeight;
|
||||
};
|
||||
|
||||
// 生成线条路径
|
||||
const generateLinePath = (data: number[]) => {
|
||||
if (!data.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// 生成SVG路径
|
||||
const generatePath = (data: number[]) => {
|
||||
const points = data.map((value, index) => {
|
||||
const x = (index / (data.length - 1)) * 380 + 10;
|
||||
const y = 200 - (value / 500) * 180 - 10;
|
||||
const x = getXPosition(index);
|
||||
const y = getYPosition(value);
|
||||
return `${x},${y}`;
|
||||
});
|
||||
|
||||
return `M ${points.join(" L ")}`;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// 简单的动画效果
|
||||
// 生成填充区域路径
|
||||
const generateAreaPath = (data: number[]) => {
|
||||
if (!data.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const points = data.map((value, index) => {
|
||||
const x = getXPosition(index);
|
||||
const y = getYPosition(value);
|
||||
return `${x},${y}`;
|
||||
});
|
||||
|
||||
const baseY = getYPosition(dataRange.value.min);
|
||||
const firstX = getXPosition(0);
|
||||
const lastX = getXPosition(data.length - 1);
|
||||
|
||||
return `M ${firstX},${baseY} L ${points.join(" L ")} L ${lastX},${baseY} Z`;
|
||||
};
|
||||
|
||||
// 数字格式化
|
||||
const formatNumber = (value: number) => {
|
||||
if (value >= 1000000) {
|
||||
return `${(value / 1000000).toFixed(1)}M`;
|
||||
} else if (value >= 1000) {
|
||||
return `${(value / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return Math.round(value).toString();
|
||||
};
|
||||
|
||||
// 动画相关
|
||||
const animatedStroke = ref("0");
|
||||
const animatedOffset = ref("0");
|
||||
|
||||
const startAnimation = () => {
|
||||
if (!chartData.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算总路径长度(近似)
|
||||
const totalLength = plotWidth + plotHeight;
|
||||
animatedStroke.value = `${totalLength}`;
|
||||
animatedOffset.value = `${totalLength}`;
|
||||
|
||||
let start = 0;
|
||||
const animate = (timestamp: number) => {
|
||||
if (!start) {
|
||||
start = timestamp;
|
||||
}
|
||||
const progress = Math.min((timestamp - start) / 2000, 1);
|
||||
const progress = Math.min((timestamp - start) / 1500, 1);
|
||||
|
||||
animatedOffset.value = `${totalLength * (1 - progress)}`;
|
||||
animationProgress.value = progress;
|
||||
|
||||
if (progress < 1) {
|
||||
@@ -46,206 +165,521 @@ onMounted(() => {
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
// 鼠标交互
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
if (!chartData.value || !chartSvg.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = chartSvg.value.getBoundingClientRect();
|
||||
const mouseX = event.clientX - rect.left;
|
||||
const mouseY = event.clientY - rect.top;
|
||||
|
||||
// 找到最近的数据点
|
||||
let closestDistance = Infinity;
|
||||
let closestDatasetIndex = -1;
|
||||
let closestPointIndex = -1;
|
||||
|
||||
chartData.value.datasets.forEach((dataset, datasetIndex) => {
|
||||
dataset.data.forEach((value, pointIndex) => {
|
||||
const x = getXPosition(pointIndex);
|
||||
const y = getYPosition(value);
|
||||
const distance = Math.sqrt((mouseX - x) ** 2 + (mouseY - y) ** 2);
|
||||
|
||||
if (distance < 30 && distance < closestDistance) {
|
||||
closestDistance = distance;
|
||||
closestDatasetIndex = datasetIndex;
|
||||
closestPointIndex = pointIndex;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (closestDatasetIndex >= 0 && closestPointIndex >= 0) {
|
||||
hoveredPoint.value = {
|
||||
datasetIndex: closestDatasetIndex,
|
||||
pointIndex: closestPointIndex,
|
||||
x: mouseX,
|
||||
y: mouseY,
|
||||
};
|
||||
} else {
|
||||
hoveredPoint.value = null;
|
||||
tooltipData.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const showTooltip = (
|
||||
event: MouseEvent,
|
||||
dataset: ChartDataset,
|
||||
pointIndex: number,
|
||||
value: number
|
||||
) => {
|
||||
if (!chartData.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = (event.target as SVGElement).getBoundingClientRect();
|
||||
const containerRect = chartSvg.value?.getBoundingClientRect();
|
||||
|
||||
if (containerRect) {
|
||||
tooltipPosition.value = {
|
||||
x: rect.left - containerRect.left + rect.width / 2,
|
||||
y: rect.top - containerRect.top - 10,
|
||||
};
|
||||
}
|
||||
|
||||
tooltipData.value = {
|
||||
time: chartData.value.labels[pointIndex],
|
||||
label: dataset.label,
|
||||
value,
|
||||
color: dataset.color,
|
||||
};
|
||||
};
|
||||
|
||||
const hideTooltip = () => {
|
||||
hoveredPoint.value = null;
|
||||
tooltipData.value = null;
|
||||
};
|
||||
|
||||
// 获取分组列表
|
||||
const fetchGroups = async () => {
|
||||
try {
|
||||
const response = await getGroupList();
|
||||
groupOptions.value = [
|
||||
{ label: "全部分组", value: null },
|
||||
...response.data.map(group => ({
|
||||
label: group.display_name || group.name,
|
||||
value: group.id || 0,
|
||||
})),
|
||||
];
|
||||
} catch (error) {
|
||||
console.error("获取分组列表失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取图表数据
|
||||
const fetchChartData = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
const response = await getDashboardChart(selectedGroup.value || undefined);
|
||||
chartData.value = response.data;
|
||||
|
||||
// 延迟启动动画,确保DOM更新完成
|
||||
setTimeout(() => {
|
||||
startAnimation();
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
console.error("获取图表数据失败:", error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 监听分组选择变化
|
||||
watch(selectedGroup, () => {
|
||||
fetchChartData();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
fetchGroups();
|
||||
fetchChartData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chart-container">
|
||||
<n-card class="chart-card modern-card" :bordered="false">
|
||||
<template #header>
|
||||
<div class="chart-header">
|
||||
<h3 class="chart-title">性能监控</h3>
|
||||
<p class="chart-subtitle">实时系统性能指标</p>
|
||||
<div class="chart-header">
|
||||
<h3 class="chart-title">24小时请求趋势</h3>
|
||||
<n-select
|
||||
v-model:value="selectedGroup"
|
||||
:options="groupOptions as any"
|
||||
placeholder="选择分组"
|
||||
size="small"
|
||||
style="width: 120px"
|
||||
clearable
|
||||
@update:value="fetchChartData"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="chartData" class="chart-content">
|
||||
<div class="chart-legend">
|
||||
<div v-for="dataset in chartData.datasets" :key="dataset.label" class="legend-item">
|
||||
<div class="legend-color" :style="{ backgroundColor: dataset.color }" />
|
||||
<span class="legend-label">{{ dataset.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div ref="chartContainer" class="chart-content">
|
||||
<div class="chart-legend">
|
||||
<div v-for="dataset in chartData.datasets" :key="dataset.label" class="legend-item">
|
||||
<div class="legend-color" :style="{ backgroundColor: dataset.color }" />
|
||||
<span class="legend-label">{{ dataset.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-area">
|
||||
<div class="chart-grid">
|
||||
<div
|
||||
v-for="(label, index) in chartData.labels"
|
||||
:key="label"
|
||||
class="grid-line"
|
||||
:style="{ left: `${(index / (chartData.labels.length - 1)) * 100}%` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<svg class="chart-svg" viewBox="0 0 400 200">
|
||||
<!-- 数据线条 -->
|
||||
<g v-for="dataset in chartData.datasets" :key="dataset.label">
|
||||
<div class="chart-wrapper">
|
||||
<svg
|
||||
ref="chartSvg"
|
||||
:width="chartWidth"
|
||||
:height="chartHeight"
|
||||
class="chart-svg"
|
||||
@mousemove="handleMouseMove"
|
||||
@mouseleave="hideTooltip"
|
||||
>
|
||||
<!-- 背景网格 -->
|
||||
<defs>
|
||||
<pattern id="grid" width="40" height="30" patternUnits="userSpaceOnUse">
|
||||
<path
|
||||
:d="generatePath(dataset.data)"
|
||||
:stroke="dataset.color"
|
||||
stroke-width="3"
|
||||
d="M 40 0 L 0 0 0 30"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="chart-line"
|
||||
:style="{
|
||||
strokeDasharray: '1000',
|
||||
strokeDashoffset: `${1000 * (1 - animationProgress)}`,
|
||||
}"
|
||||
stroke="#f0f0f0"
|
||||
stroke-width="1"
|
||||
opacity="0.3"
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||
|
||||
<!-- 数据点 -->
|
||||
<g v-for="(value, index) in dataset.data" :key="index">
|
||||
<circle
|
||||
:cx="(index / (dataset.data.length - 1)) * 380 + 10"
|
||||
:cy="200 - (value / 500) * 180 - 10"
|
||||
:r="animationProgress > index / dataset.data.length ? 4 : 0"
|
||||
:fill="dataset.color"
|
||||
class="chart-point"
|
||||
/>
|
||||
</g>
|
||||
<!-- Y轴刻度线和标签 -->
|
||||
<g class="y-axis">
|
||||
<line
|
||||
:x1="padding.left"
|
||||
:y1="padding.top"
|
||||
:x2="padding.left"
|
||||
:y2="chartHeight - padding.bottom"
|
||||
stroke="#e0e0e0"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<g v-for="(tick, index) in yTicks" :key="index">
|
||||
<line
|
||||
:x1="padding.left - 5"
|
||||
:y1="getYPosition(tick)"
|
||||
:x2="padding.left"
|
||||
:y2="getYPosition(tick)"
|
||||
stroke="#666"
|
||||
stroke-width="1"
|
||||
/>
|
||||
<text
|
||||
:x="padding.left - 10"
|
||||
:y="getYPosition(tick) + 4"
|
||||
text-anchor="end"
|
||||
class="axis-label"
|
||||
>
|
||||
{{ formatNumber(tick) }}
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
</g>
|
||||
|
||||
<div class="chart-labels">
|
||||
<div
|
||||
v-for="(label, index) in chartData.labels"
|
||||
:key="label"
|
||||
class="chart-label"
|
||||
:style="{ left: `${(index / (chartData.labels.length - 1)) * 100}%` }"
|
||||
>
|
||||
{{ label }}
|
||||
</div>
|
||||
<!-- X轴刻度线和标签 -->
|
||||
<g class="x-axis">
|
||||
<line
|
||||
:x1="padding.left"
|
||||
:y1="chartHeight - padding.bottom"
|
||||
:x2="chartWidth - padding.right"
|
||||
:y2="chartHeight - padding.bottom"
|
||||
stroke="#e0e0e0"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<g v-for="(label, index) in visibleLabels" :key="index">
|
||||
<line
|
||||
:x1="getXPosition(label.index)"
|
||||
:y1="chartHeight - padding.bottom"
|
||||
:x2="getXPosition(label.index)"
|
||||
:y2="chartHeight - padding.bottom + 5"
|
||||
stroke="#666"
|
||||
stroke-width="1"
|
||||
/>
|
||||
<text
|
||||
:x="getXPosition(label.index)"
|
||||
:y="chartHeight - padding.bottom + 18"
|
||||
text-anchor="middle"
|
||||
class="axis-label"
|
||||
>
|
||||
{{ label.text }}
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- 数据线条 -->
|
||||
<g v-for="(dataset, datasetIndex) in chartData.datasets" :key="dataset.label">
|
||||
<!-- 渐变定义 -->
|
||||
<defs>
|
||||
<linearGradient :id="`gradient-${datasetIndex}`" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" :stop-color="dataset.color" stop-opacity="0.3" />
|
||||
<stop offset="100%" :stop-color="dataset.color" stop-opacity="0.05" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- 填充区域 -->
|
||||
<path
|
||||
:d="generateAreaPath(dataset.data)"
|
||||
:fill="`url(#gradient-${datasetIndex})`"
|
||||
class="area-path"
|
||||
/>
|
||||
|
||||
<!-- 主线条 -->
|
||||
<path
|
||||
:d="generateLinePath(dataset.data)"
|
||||
:stroke="dataset.color"
|
||||
stroke-width="3"
|
||||
fill="none"
|
||||
class="line-path"
|
||||
:style="{
|
||||
strokeDasharray: animatedStroke,
|
||||
strokeDashoffset: animatedOffset,
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.1))',
|
||||
}"
|
||||
/>
|
||||
|
||||
<!-- 数据点 -->
|
||||
<g v-for="(value, pointIndex) in dataset.data" :key="pointIndex">
|
||||
<circle
|
||||
:cx="getXPosition(pointIndex)"
|
||||
:cy="getYPosition(value)"
|
||||
r="4"
|
||||
:fill="dataset.color"
|
||||
:stroke="dataset.color"
|
||||
stroke-width="2"
|
||||
class="data-point"
|
||||
:class="{
|
||||
'point-hover':
|
||||
hoveredPoint?.datasetIndex === datasetIndex &&
|
||||
hoveredPoint?.pointIndex === pointIndex,
|
||||
}"
|
||||
@mouseenter="showTooltip($event, dataset, pointIndex, value)"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- 悬停指示线 -->
|
||||
<line
|
||||
v-if="hoveredPoint"
|
||||
:x1="getXPosition(hoveredPoint.pointIndex)"
|
||||
:y1="padding.top"
|
||||
:x2="getXPosition(hoveredPoint.pointIndex)"
|
||||
:y2="chartHeight - padding.bottom"
|
||||
stroke="#999"
|
||||
stroke-width="1"
|
||||
stroke-dasharray="5,5"
|
||||
opacity="0.7"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- 提示框 -->
|
||||
<div
|
||||
v-if="tooltipData"
|
||||
class="chart-tooltip"
|
||||
:style="{
|
||||
left: tooltipPosition.x + 'px',
|
||||
top: tooltipPosition.y + 'px',
|
||||
}"
|
||||
>
|
||||
<div class="tooltip-time">{{ tooltipData.time }}</div>
|
||||
<div class="tooltip-value">
|
||||
<span class="tooltip-color" :style="{ backgroundColor: tooltipData.color }" />
|
||||
{{ tooltipData.label }}: {{ formatNumber(tooltipData.value) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
<div v-else class="chart-loading">
|
||||
<n-spin size="large" />
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chart-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(31, 38, 135, 0.37);
|
||||
backdrop-filter: blur(4px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
text-align: center;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.chart-subtitle {
|
||||
font-size: 0.9rem;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(45deg, #fff, #f0f0f0);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.chart-content {
|
||||
position: relative;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
font-size: 0.9rem;
|
||||
color: #374151;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.chart-area {
|
||||
.chart-wrapper {
|
||||
position: relative;
|
||||
height: 240px;
|
||||
background: linear-gradient(180deg, rgba(102, 126, 234, 0.02) 0%, rgba(102, 126, 234, 0.08) 100%);
|
||||
border-radius: var(--border-radius-md);
|
||||
border: 1px solid rgba(102, 126, 234, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chart-grid {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.grid-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.chart-svg {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.chart-line {
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1));
|
||||
transition: stroke-dashoffset 0.2s ease-out;
|
||||
.axis-label {
|
||||
fill: #666;
|
||||
font-size: 12px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.chart-point {
|
||||
transition: r 0.2s ease;
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
|
||||
.line-path {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.chart-point:hover {
|
||||
.area-path {
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.data-point {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.data-point:hover,
|
||||
.point-hover {
|
||||
r: 6;
|
||||
filter: drop-shadow(0 0 8px rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
|
||||
.chart-labels {
|
||||
.chart-tooltip {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 40px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
transform: translateX(-50%) translateY(-100%);
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(4px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.tooltip-time {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tooltip-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chart-label {
|
||||
position: absolute;
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
backdrop-filter: blur(4px);
|
||||
.tooltip-color {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.chart-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 300px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.chart-loading p {
|
||||
margin-top: 16px;
|
||||
font-size: 16px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.chart-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.chart-svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
animation: fadeInUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
animation: fadeInUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
.legend-item:nth-child(2) {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
|
||||
.legend-item:nth-child(3) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
</style>
|
||||
|
@@ -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[];
|
||||
}
|
||||
|
Reference in New Issue
Block a user