feat: 仪表盘
This commit is contained in:
@@ -1,21 +1,15 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
app_errors "gpt-load/internal/errors"
|
app_errors "gpt-load/internal/errors"
|
||||||
"gpt-load/internal/models"
|
"gpt-load/internal/models"
|
||||||
"gpt-load/internal/response"
|
"gpt-load/internal/response"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Stats godoc
|
// Stats Get dashboard statistics
|
||||||
// @Summary Get dashboard statistics
|
|
||||||
// @Description Get statistics for the dashboard cards
|
|
||||||
// @Tags Dashboard
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Success 200 {object} response.Response{data=models.DashboardStatsResponse}
|
|
||||||
// @Router /dashboard/stats [get]
|
|
||||||
func (s *Server) Stats(c *gin.Context) {
|
func (s *Server) Stats(c *gin.Context) {
|
||||||
var activeKeys, invalidKeys, groupCount int64
|
var activeKeys, invalidKeys, groupCount int64
|
||||||
s.DB.Model(&models.APIKey{}).Where("status = ?", models.KeyStatusActive).Count(&activeKeys)
|
s.DB.Model(&models.APIKey{}).Where("status = ?", models.KeyStatusActive).Count(&activeKeys)
|
||||||
@@ -37,11 +31,24 @@ func (s *Server) Stats(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 计算请求量趋势
|
||||||
reqTrend := 0.0
|
reqTrend := 0.0
|
||||||
|
reqTrendIsGrowth := true
|
||||||
if previousPeriod.TotalRequests > 0 {
|
if previousPeriod.TotalRequests > 0 {
|
||||||
|
// 有前期数据,计算百分比变化
|
||||||
reqTrend = (float64(currentPeriod.TotalRequests-previousPeriod.TotalRequests) / float64(previousPeriod.TotalRequests)) * 100
|
reqTrend = (float64(currentPeriod.TotalRequests-previousPeriod.TotalRequests) / float64(previousPeriod.TotalRequests)) * 100
|
||||||
|
reqTrendIsGrowth = reqTrend >= 0
|
||||||
|
} else if currentPeriod.TotalRequests > 0 {
|
||||||
|
// 前期无数据,当前有数据,视为100%增长
|
||||||
|
reqTrend = 100.0
|
||||||
|
reqTrendIsGrowth = true
|
||||||
|
} else {
|
||||||
|
// 前期和当前都无数据
|
||||||
|
reqTrend = 0.0
|
||||||
|
reqTrendIsGrowth = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 计算当前和前期错误率
|
||||||
currentErrorRate := 0.0
|
currentErrorRate := 0.0
|
||||||
if currentPeriod.TotalRequests > 0 {
|
if currentPeriod.TotalRequests > 0 {
|
||||||
currentErrorRate = (float64(currentPeriod.TotalFailures) / float64(currentPeriod.TotalRequests)) * 100
|
currentErrorRate = (float64(currentPeriod.TotalFailures) / float64(currentPeriod.TotalRequests)) * 100
|
||||||
@@ -52,7 +59,25 @@ func (s *Server) Stats(c *gin.Context) {
|
|||||||
previousErrorRate = (float64(previousPeriod.TotalFailures) / float64(previousPeriod.TotalRequests)) * 100
|
previousErrorRate = (float64(previousPeriod.TotalFailures) / float64(previousPeriod.TotalRequests)) * 100
|
||||||
}
|
}
|
||||||
|
|
||||||
errorRateTrend := currentErrorRate - previousErrorRate
|
// 计算错误率趋势
|
||||||
|
errorRateTrend := 0.0
|
||||||
|
errorRateTrendIsGrowth := false
|
||||||
|
if previousPeriod.TotalRequests > 0 {
|
||||||
|
// 有前期数据,计算百分点差异
|
||||||
|
errorRateTrend = currentErrorRate - previousErrorRate
|
||||||
|
errorRateTrendIsGrowth = errorRateTrend < 0 // 错误率下降是好事
|
||||||
|
} else if currentPeriod.TotalRequests > 0 {
|
||||||
|
// 前期无数据,当前有数据
|
||||||
|
errorRateTrend = currentErrorRate // 显示当前错误率
|
||||||
|
errorRateTrendIsGrowth = false // 有错误是坏事(如果错误率>0)
|
||||||
|
if currentErrorRate == 0 {
|
||||||
|
errorRateTrendIsGrowth = true // 如果当前无错误,标记为正面
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 都无数据
|
||||||
|
errorRateTrend = 0.0
|
||||||
|
errorRateTrendIsGrowth = true
|
||||||
|
}
|
||||||
|
|
||||||
stats := models.DashboardStatsResponse{
|
stats := models.DashboardStatsResponse{
|
||||||
KeyCount: models.StatCard{
|
KeyCount: models.StatCard{
|
||||||
@@ -66,28 +91,19 @@ func (s *Server) Stats(c *gin.Context) {
|
|||||||
RequestCount: models.StatCard{
|
RequestCount: models.StatCard{
|
||||||
Value: float64(currentPeriod.TotalRequests),
|
Value: float64(currentPeriod.TotalRequests),
|
||||||
Trend: reqTrend,
|
Trend: reqTrend,
|
||||||
TrendIsGrowth: reqTrend >= 0,
|
TrendIsGrowth: reqTrendIsGrowth,
|
||||||
},
|
},
|
||||||
ErrorRate: models.StatCard{
|
ErrorRate: models.StatCard{
|
||||||
Value: currentErrorRate,
|
Value: currentErrorRate,
|
||||||
Trend: errorRateTrend,
|
Trend: errorRateTrend,
|
||||||
TrendIsGrowth: errorRateTrend < 0, // 错误率下降是好事
|
TrendIsGrowth: errorRateTrendIsGrowth,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
response.Success(c, stats)
|
response.Success(c, stats)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Chart Get dashboard chart data
|
||||||
// Chart godoc
|
|
||||||
// @Summary Get dashboard chart data
|
|
||||||
// @Description Get chart data for the last 24 hours
|
|
||||||
// @Tags Dashboard
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param groupId query int false "Group ID"
|
|
||||||
// @Success 200 {object} response.Response{data=models.ChartData}
|
|
||||||
// @Router /dashboard/chart [get]
|
|
||||||
func (s *Server) Chart(c *gin.Context) {
|
func (s *Server) Chart(c *gin.Context) {
|
||||||
groupID := c.Query("groupId")
|
groupID := c.Query("groupId")
|
||||||
|
|
||||||
@@ -95,7 +111,7 @@ func (s *Server) Chart(c *gin.Context) {
|
|||||||
twentyFourHoursAgo := now.Add(-24 * time.Hour)
|
twentyFourHoursAgo := now.Add(-24 * time.Hour)
|
||||||
|
|
||||||
var hourlyStats []models.GroupHourlyStat
|
var hourlyStats []models.GroupHourlyStat
|
||||||
query := s.DB.Where("time >= ?", twentyFourHoursAgo)
|
query := s.DB.Where("time >= ? AND time < ?", twentyFourHoursAgo, now)
|
||||||
if groupID != "" {
|
if groupID != "" {
|
||||||
query = query.Where("group_id = ?", groupID)
|
query = query.Where("group_id = ?", groupID)
|
||||||
}
|
}
|
||||||
@@ -149,7 +165,6 @@ func (s *Server) Chart(c *gin.Context) {
|
|||||||
response.Success(c, chartData)
|
response.Success(c, chartData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
type hourlyStatResult struct {
|
type hourlyStatResult struct {
|
||||||
TotalRequests int64
|
TotalRequests int64
|
||||||
TotalFailures int64
|
TotalFailures int64
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { getDashboardChart, getGroupList } from "@/api/dashboard";
|
import { getDashboardChart, getGroupList } from "@/api/dashboard";
|
||||||
import type { ChartData, ChartDataset } from "@/types/models";
|
import type { ChartData } from "@/types/models";
|
||||||
|
import { getGroupDisplayName } from "@/utils/display";
|
||||||
import { NSelect, NSpin } from "naive-ui";
|
import { NSelect, NSpin } from "naive-ui";
|
||||||
import { computed, onMounted, ref, watch } from "vue";
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
|
|
||||||
@@ -17,16 +18,18 @@ const hoveredPoint = ref<{
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const tooltipData = ref<{
|
const tooltipData = ref<{
|
||||||
time: string;
|
time: string;
|
||||||
|
datasets: Array<{
|
||||||
label: string;
|
label: string;
|
||||||
value: number;
|
value: number;
|
||||||
color: string;
|
color: string;
|
||||||
|
}>;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const tooltipPosition = ref({ x: 0, y: 0 });
|
const tooltipPosition = ref({ x: 0, y: 0 });
|
||||||
const chartSvg = ref<SVGElement>();
|
const chartSvg = ref<SVGElement>();
|
||||||
|
|
||||||
// 图表尺寸和边距
|
// 图表尺寸和边距
|
||||||
const chartWidth = 800;
|
const chartWidth = 800;
|
||||||
const chartHeight = 400;
|
const chartHeight = 260;
|
||||||
const padding = { top: 40, right: 40, bottom: 60, left: 80 };
|
const padding = { top: 40, right: 40, bottom: 60, left: 80 };
|
||||||
|
|
||||||
// 格式化分组选项
|
// 格式化分组选项
|
||||||
@@ -46,8 +49,13 @@ const dataRange = computed(() => {
|
|||||||
const max = Math.max(...allValues, 0);
|
const max = Math.max(...allValues, 0);
|
||||||
const min = Math.min(...allValues, 0);
|
const min = Math.min(...allValues, 0);
|
||||||
|
|
||||||
|
// 如果所有数据都是0,设置一个合理的范围
|
||||||
|
if (max === 0 && min === 0) {
|
||||||
|
return { min: 0, max: 10 };
|
||||||
|
}
|
||||||
|
|
||||||
// 添加一些padding让图表更好看
|
// 添加一些padding让图表更好看
|
||||||
const paddingValue = (max - min) * 0.1;
|
const paddingValue = Math.max((max - min) * 0.1, 1);
|
||||||
return {
|
return {
|
||||||
min: Math.max(0, min - paddingValue),
|
min: Math.max(0, min - paddingValue),
|
||||||
max: max + paddingValue,
|
max: max + paddingValue,
|
||||||
@@ -174,69 +182,70 @@ const handleMouseMove = (event: MouseEvent) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rect = chartSvg.value.getBoundingClientRect();
|
const rect = chartSvg.value.getBoundingClientRect();
|
||||||
const mouseX = event.clientX - rect.left;
|
// 考虑SVG的viewBox缩放
|
||||||
const mouseY = event.clientY - rect.top;
|
const scaleX = 800 / rect.width;
|
||||||
|
const scaleY = 260 / rect.height;
|
||||||
|
|
||||||
// 找到最近的数据点
|
const mouseX = (event.clientX - rect.left) * scaleX;
|
||||||
let closestDistance = Infinity;
|
const mouseY = (event.clientY - rect.top) * scaleY;
|
||||||
let closestDatasetIndex = -1;
|
|
||||||
let closestPointIndex = -1;
|
|
||||||
|
|
||||||
chartData.value.datasets.forEach((dataset, datasetIndex) => {
|
// 首先找到最接近的X轴位置(时间点)
|
||||||
dataset.data.forEach((value, pointIndex) => {
|
let closestXDistance = Infinity;
|
||||||
|
let closestTimeIndex = -1;
|
||||||
|
|
||||||
|
chartData.value.labels.forEach((_, pointIndex) => {
|
||||||
const x = getXPosition(pointIndex);
|
const x = getXPosition(pointIndex);
|
||||||
const y = getYPosition(value);
|
const xDistance = Math.abs(mouseX - x);
|
||||||
const distance = Math.sqrt((mouseX - x) ** 2 + (mouseY - y) ** 2);
|
|
||||||
|
|
||||||
if (distance < 30 && distance < closestDistance) {
|
if (xDistance < closestXDistance) {
|
||||||
closestDistance = distance;
|
closestXDistance = xDistance;
|
||||||
closestDatasetIndex = datasetIndex;
|
closestTimeIndex = pointIndex;
|
||||||
closestPointIndex = pointIndex;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
if (closestDatasetIndex >= 0 && closestPointIndex >= 0) {
|
// 如果鼠标距离最近的时间点太远,不显示提示
|
||||||
|
if (closestXDistance > 50) {
|
||||||
|
hoveredPoint.value = null;
|
||||||
|
tooltipData.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集该时间点所有数据集的数据
|
||||||
|
const datasetsAtTime = chartData.value.datasets.map(dataset => ({
|
||||||
|
label: dataset.label,
|
||||||
|
value: dataset.data[closestTimeIndex],
|
||||||
|
color: dataset.color,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (closestTimeIndex >= 0) {
|
||||||
hoveredPoint.value = {
|
hoveredPoint.value = {
|
||||||
datasetIndex: closestDatasetIndex,
|
datasetIndex: 0, // 不再需要特定的数据集索引
|
||||||
pointIndex: closestPointIndex,
|
pointIndex: closestTimeIndex,
|
||||||
x: mouseX,
|
x: mouseX,
|
||||||
y: mouseY,
|
y: mouseY,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 显示 tooltip
|
||||||
|
const x = getXPosition(closestTimeIndex);
|
||||||
|
const avgY =
|
||||||
|
datasetsAtTime.reduce((sum, item) => sum + getYPosition(item.value), 0) /
|
||||||
|
datasetsAtTime.length;
|
||||||
|
|
||||||
|
tooltipPosition.value = {
|
||||||
|
x,
|
||||||
|
y: avgY - 20, // 在平均高度上方显示
|
||||||
|
};
|
||||||
|
|
||||||
|
tooltipData.value = {
|
||||||
|
time: chartData.value.labels[closestTimeIndex],
|
||||||
|
datasets: datasetsAtTime,
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
hoveredPoint.value = null;
|
hoveredPoint.value = null;
|
||||||
tooltipData.value = null;
|
tooltipData.value = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const showTooltip = (
|
|
||||||
event: MouseEvent,
|
|
||||||
dataset: ChartDataset,
|
|
||||||
pointIndex: number,
|
|
||||||
value: number
|
|
||||||
) => {
|
|
||||||
if (!chartData.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rect = (event.target as SVGElement).getBoundingClientRect();
|
|
||||||
const containerRect = chartSvg.value?.getBoundingClientRect();
|
|
||||||
|
|
||||||
if (containerRect) {
|
|
||||||
tooltipPosition.value = {
|
|
||||||
x: rect.left - containerRect.left + rect.width / 2,
|
|
||||||
y: rect.top - containerRect.top - 10,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
tooltipData.value = {
|
|
||||||
time: chartData.value.labels[pointIndex],
|
|
||||||
label: dataset.label,
|
|
||||||
value,
|
|
||||||
color: dataset.color,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const hideTooltip = () => {
|
const hideTooltip = () => {
|
||||||
hoveredPoint.value = null;
|
hoveredPoint.value = null;
|
||||||
tooltipData.value = null;
|
tooltipData.value = null;
|
||||||
@@ -249,7 +258,7 @@ const fetchGroups = async () => {
|
|||||||
groupOptions.value = [
|
groupOptions.value = [
|
||||||
{ label: "全部分组", value: null },
|
{ label: "全部分组", value: null },
|
||||||
...response.data.map(group => ({
|
...response.data.map(group => ({
|
||||||
label: group.display_name || group.name,
|
label: getGroupDisplayName(group),
|
||||||
value: group.id || 0,
|
value: group.id || 0,
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
@@ -290,13 +299,16 @@ onMounted(() => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="chart-container">
|
<div class="chart-container">
|
||||||
<div class="chart-header">
|
<div class="chart-header">
|
||||||
|
<div class="chart-title-section">
|
||||||
<h3 class="chart-title">24小时请求趋势</h3>
|
<h3 class="chart-title">24小时请求趋势</h3>
|
||||||
|
<p class="chart-subtitle">实时监控系统请求状态</p>
|
||||||
|
</div>
|
||||||
<n-select
|
<n-select
|
||||||
v-model:value="selectedGroup"
|
v-model:value="selectedGroup"
|
||||||
:options="groupOptions as any"
|
:options="groupOptions as any"
|
||||||
placeholder="选择分组"
|
placeholder="全部分组"
|
||||||
size="small"
|
size="small"
|
||||||
style="width: 120px"
|
style="width: 150px"
|
||||||
clearable
|
clearable
|
||||||
@update:value="fetchChartData"
|
@update:value="fetchChartData"
|
||||||
/>
|
/>
|
||||||
@@ -305,7 +317,7 @@ onMounted(() => {
|
|||||||
<div v-if="chartData" class="chart-content">
|
<div v-if="chartData" class="chart-content">
|
||||||
<div class="chart-legend">
|
<div class="chart-legend">
|
||||||
<div v-for="dataset in chartData.datasets" :key="dataset.label" class="legend-item">
|
<div v-for="dataset in chartData.datasets" :key="dataset.label" class="legend-item">
|
||||||
<div class="legend-color" :style="{ backgroundColor: dataset.color }" />
|
<div class="legend-indicator" :style="{ backgroundColor: dataset.color }" />
|
||||||
<span class="legend-label">{{ dataset.label }}</span>
|
<span class="legend-label">{{ dataset.label }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -313,8 +325,7 @@ onMounted(() => {
|
|||||||
<div class="chart-wrapper">
|
<div class="chart-wrapper">
|
||||||
<svg
|
<svg
|
||||||
ref="chartSvg"
|
ref="chartSvg"
|
||||||
:width="chartWidth"
|
viewBox="0 0 800 260"
|
||||||
:height="chartHeight"
|
|
||||||
class="chart-svg"
|
class="chart-svg"
|
||||||
@mousemove="handleMouseMove"
|
@mousemove="handleMouseMove"
|
||||||
@mouseleave="hideTooltip"
|
@mouseleave="hideTooltip"
|
||||||
@@ -435,11 +446,8 @@ onMounted(() => {
|
|||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
class="data-point"
|
class="data-point"
|
||||||
:class="{
|
:class="{
|
||||||
'point-hover':
|
'point-hover': hoveredPoint?.pointIndex === pointIndex,
|
||||||
hoveredPoint?.datasetIndex === datasetIndex &&
|
|
||||||
hoveredPoint?.pointIndex === pointIndex,
|
|
||||||
}"
|
}"
|
||||||
@mouseenter="showTooltip($event, dataset, pointIndex, value)"
|
|
||||||
/>
|
/>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
@@ -468,9 +476,9 @@ onMounted(() => {
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div class="tooltip-time">{{ tooltipData.time }}</div>
|
<div class="tooltip-time">{{ tooltipData.time }}</div>
|
||||||
<div class="tooltip-value">
|
<div v-for="dataset in tooltipData.datasets" :key="dataset.label" class="tooltip-value">
|
||||||
<span class="tooltip-color" :style="{ backgroundColor: tooltipData.color }" />
|
<span class="tooltip-color" :style="{ backgroundColor: dataset.color }" />
|
||||||
{{ tooltipData.label }}: {{ formatNumber(tooltipData.value) }}
|
{{ dataset.label }}: {{ formatNumber(dataset.value) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -497,12 +505,17 @@ onMounted(() => {
|
|||||||
.chart-header {
|
.chart-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-title-section {
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-title {
|
.chart-title {
|
||||||
margin: 0;
|
margin: 0 0 4px 0;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: linear-gradient(45deg, #fff, #f0f0f0);
|
background: linear-gradient(45deg, #fff, #f0f0f0);
|
||||||
@@ -511,37 +524,70 @@ onMounted(() => {
|
|||||||
background-clip: text;
|
background-clip: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chart-subtitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
.chart-content {
|
.chart-content {
|
||||||
background: rgba(255, 255, 255, 0.95);
|
background: rgba(255, 255, 255, 0.95);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 20px;
|
padding: 12px;
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-legend {
|
.chart-legend {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 24px;
|
gap: 12px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-item {
|
.legend-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #475569;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.6);
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid rgba(226, 232, 240, 0.6);
|
||||||
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-color {
|
.legend-item:hover {
|
||||||
width: 16px;
|
background: rgba(255, 255, 255, 0.9);
|
||||||
height: 16px;
|
transform: translateY(-1px);
|
||||||
border-radius: 50%;
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-indicator {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 3px;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-indicator::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-label {
|
.legend-label {
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
color: #666;
|
color: #334155;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-wrapper {
|
.chart-wrapper {
|
||||||
@@ -584,27 +630,42 @@ onMounted(() => {
|
|||||||
|
|
||||||
.chart-tooltip {
|
.chart-tooltip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
background: rgba(0, 0, 0, 0.8);
|
background: rgba(0, 0, 0, 0.9);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 8px 12px;
|
padding: 12px 16px;
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transform: translateX(-50%) translateY(-100%);
|
transform: translateX(-50%) translateY(-100%);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(8px);
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
min-width: 140px;
|
||||||
|
max-width: 220px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-time {
|
.tooltip-time {
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 8px;
|
||||||
|
text-align: center;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 12px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
padding-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-value {
|
.tooltip-value {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-value:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-color {
|
.tooltip-color {
|
||||||
@@ -618,7 +679,7 @@ onMounted(() => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 300px;
|
height: 260px;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user