- 新增Redis监控主页面,展示Redis服务器状态和性能指标 - 实现命令统计饼图组件,支持SVG绘制和交互悬停效果 - 实现内存消耗仪表盘组件,采用圆环进度条设计 - 智能处理Redis无限制内存配置的显示逻辑 - 集成Redis基本信息展示和实时数据刷新功能
333 lines
7.2 KiB
Vue
333 lines
7.2 KiB
Vue
<template>
|
||
<div class="pie-chart-container">
|
||
<div v-if="!data || data.length === 0" class="empty-state">
|
||
<n-empty description="暂无命令统计数据">
|
||
<template #icon>
|
||
<n-icon><IconParkOutlineChartPie /></n-icon>
|
||
</template>
|
||
</n-empty>
|
||
</div>
|
||
<div v-else class="chart-content">
|
||
<!-- SVG饼图 -->
|
||
<svg class="pie-chart" :width="chartSize" :height="chartSize" :viewBox="`0 0 ${chartSize} ${chartSize}`">
|
||
<!-- 饼图扇形 -->
|
||
<g :transform="`translate(${chartSize / 2}, ${chartSize / 2})`">
|
||
<path
|
||
v-for="(segment, index) in pieSegments"
|
||
:key="index"
|
||
:d="segment.path"
|
||
:fill="segment.color"
|
||
stroke="var(--card-color)"
|
||
:stroke-width="2"
|
||
class="pie-segment"
|
||
@mouseenter="hoveredIndex = index"
|
||
@mouseleave="hoveredIndex = -1"
|
||
/>
|
||
</g>
|
||
|
||
<!-- 中心文本 -->
|
||
<g :transform="`translate(${chartSize / 2}, ${chartSize / 2})`">
|
||
<text
|
||
class="center-text-title"
|
||
text-anchor="middle"
|
||
:y="-8"
|
||
fill="var(--text-color-1)"
|
||
>
|
||
命令统计
|
||
</text>
|
||
<text
|
||
class="center-text-value"
|
||
text-anchor="middle"
|
||
:y="12"
|
||
fill="var(--text-color-2)"
|
||
>
|
||
{{ totalCommands.toLocaleString() }}
|
||
</text>
|
||
</g>
|
||
</svg>
|
||
|
||
<!-- 图例 -->
|
||
<div class="chart-legend">
|
||
<div
|
||
v-for="(item, index) in topCommands"
|
||
:key="index"
|
||
class="legend-item"
|
||
:class="{ active: hoveredIndex === index }"
|
||
@mouseenter="hoveredIndex = index"
|
||
@mouseleave="hoveredIndex = -1"
|
||
>
|
||
<div class="legend-color" :style="{ backgroundColor: colors[index] }" />
|
||
<div class="legend-content">
|
||
<div class="legend-name">
|
||
{{ item.name }}
|
||
</div>
|
||
<div class="legend-value">
|
||
{{ Number(item.value).toLocaleString() }} ({{ item.percentage }}%)
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="otherCommands > 0" class="legend-item">
|
||
<div class="legend-color" :style="{ backgroundColor: colors[topCommands.length] }" />
|
||
<div class="legend-content">
|
||
<div class="legend-name">
|
||
其他
|
||
</div>
|
||
<div class="legend-value">
|
||
{{ otherCommands.toLocaleString() }} ({{ otherPercentage }}%)
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, ref } from 'vue'
|
||
import type { RedisCommandStatVo } from '@/service/api/monitor/redis'
|
||
|
||
// Props
|
||
interface Props {
|
||
data: RedisCommandStatVo[]
|
||
}
|
||
|
||
const props = defineProps<Props>()
|
||
|
||
// 响应式数据
|
||
const hoveredIndex = ref(-1)
|
||
const chartSize = 240
|
||
const radius = 80
|
||
|
||
// 颜色配置
|
||
const colors = [
|
||
'#18a058',
|
||
'#2080f0',
|
||
'#f0a020',
|
||
'#d03050',
|
||
'#722ed1',
|
||
'#eb2f96',
|
||
'#fa8c16',
|
||
'#52c41a',
|
||
'#13c2c2',
|
||
'#1890ff',
|
||
]
|
||
|
||
// 计算属性
|
||
const totalCommands = computed(() => {
|
||
return props.data.reduce((sum, item) => sum + Number(item.value), 0)
|
||
})
|
||
|
||
// 取前8个命令,其余归为"其他"
|
||
const topCommands = computed(() => {
|
||
const sorted = [...props.data]
|
||
.sort((a, b) => Number(b.value) - Number(a.value))
|
||
.slice(0, 8)
|
||
|
||
return sorted.map(item => ({
|
||
...item,
|
||
percentage: totalCommands.value > 0
|
||
? ((Number(item.value) / totalCommands.value) * 100).toFixed(1)
|
||
: '0.0',
|
||
}))
|
||
})
|
||
|
||
const otherCommands = computed(() => {
|
||
if (props.data.length <= 8)
|
||
return 0
|
||
|
||
const topTotal = topCommands.value.reduce((sum, item) => sum + Number(item.value), 0)
|
||
return totalCommands.value - topTotal
|
||
})
|
||
|
||
const otherPercentage = computed(() => {
|
||
if (otherCommands.value === 0 || totalCommands.value === 0)
|
||
return '0.0'
|
||
return ((otherCommands.value / totalCommands.value) * 100).toFixed(1)
|
||
})
|
||
|
||
// 饼图扇形数据
|
||
const pieSegments = computed(() => {
|
||
const segments = []
|
||
let currentAngle = -Math.PI / 2 // 从12点钟方向开始
|
||
|
||
// 添加主要命令扇形
|
||
topCommands.value.forEach((item, index) => {
|
||
const percentage = Number(item.value) / totalCommands.value
|
||
const angle = percentage * 2 * Math.PI
|
||
|
||
if (angle > 0) {
|
||
const path = createArcPath(currentAngle, currentAngle + angle, radius)
|
||
segments.push({
|
||
path,
|
||
color: colors[index],
|
||
percentage: item.percentage,
|
||
})
|
||
currentAngle += angle
|
||
}
|
||
})
|
||
|
||
// 添加"其他"扇形
|
||
if (otherCommands.value > 0) {
|
||
const percentage = otherCommands.value / totalCommands.value
|
||
const angle = percentage * 2 * Math.PI
|
||
|
||
if (angle > 0) {
|
||
const path = createArcPath(currentAngle, currentAngle + angle, radius)
|
||
segments.push({
|
||
path,
|
||
color: colors[topCommands.value.length],
|
||
percentage: otherPercentage.value,
|
||
})
|
||
}
|
||
}
|
||
|
||
return segments
|
||
})
|
||
|
||
// 创建弧形路径
|
||
function createArcPath(startAngle: number, endAngle: number, radius: number): string {
|
||
const x1 = Math.cos(startAngle) * radius
|
||
const y1 = Math.sin(startAngle) * radius
|
||
const x2 = Math.cos(endAngle) * radius
|
||
const y2 = Math.sin(endAngle) * radius
|
||
|
||
const largeArcFlag = endAngle - startAngle > Math.PI ? 1 : 0
|
||
|
||
return [
|
||
'M',
|
||
0,
|
||
0,
|
||
'L',
|
||
x1,
|
||
y1,
|
||
'A',
|
||
radius,
|
||
radius,
|
||
0,
|
||
largeArcFlag,
|
||
1,
|
||
x2,
|
||
y2,
|
||
'Z',
|
||
].join(' ')
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.pie-chart-container {
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 20px;
|
||
}
|
||
|
||
.empty-state {
|
||
height: 200px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.chart-content {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 32px;
|
||
width: 100%;
|
||
max-width: 600px;
|
||
justify-content: center;
|
||
}
|
||
|
||
.pie-chart {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.pie-segment {
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.pie-segment:hover {
|
||
filter: brightness(1.1);
|
||
transform: scale(1.02);
|
||
}
|
||
|
||
.center-text-title {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.center-text-value {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.chart-legend {
|
||
flex: 1;
|
||
max-height: 280px;
|
||
overflow-y: auto;
|
||
padding-right: 8px;
|
||
}
|
||
|
||
.legend-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 8px 12px;
|
||
border-radius: 6px;
|
||
transition: all 0.2s ease;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.legend-item:hover,
|
||
.legend-item.active {
|
||
background-color: var(--hover-color);
|
||
}
|
||
|
||
.legend-color {
|
||
width: 12px;
|
||
height: 12px;
|
||
border-radius: 2px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.legend-content {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.legend-name {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: var(--text-color-1);
|
||
margin-bottom: 2px;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.legend-value {
|
||
font-size: 12px;
|
||
color: var(--text-color-3);
|
||
}
|
||
|
||
/* 滚动条样式 */
|
||
.chart-legend::-webkit-scrollbar {
|
||
width: 4px;
|
||
}
|
||
|
||
.chart-legend::-webkit-scrollbar-track {
|
||
background: var(--scrollbar-track-color);
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.chart-legend::-webkit-scrollbar-thumb {
|
||
background: var(--scrollbar-thumb-color);
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.chart-legend::-webkit-scrollbar-thumb:hover {
|
||
background: var(--scrollbar-thumb-hover-color);
|
||
}
|
||
</style>
|