feat(dashboard): optimize dashboard layout and add new charts

This commit is contained in:
祀梦
2026-04-05 21:04:49 +08:00
parent 7d5eaa438a
commit 02cd37db71
11 changed files with 967 additions and 657 deletions

View File

@@ -0,0 +1,270 @@
<template>
<el-card class="chart-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">
<el-icon class="header-icon"><Histogram /></el-icon>
延迟分布
</span>
<el-tooltip content="已验证可用代理的响应延迟分布">
<el-icon class="help-icon"><InfoFilled /></el-icon>
</el-tooltip>
</div>
</template>
<div ref="chartRef" class="chart-container"></div>
</el-card>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { InfoFilled, Histogram } from '@element-plus/icons-vue'
import * as echarts from 'echarts'
import axios from '../api'
const chartRef = ref(null)
let chartInstance = null
let resizeTimer = null
const cachedColors = ref(null)
function loadColors() {
if (cachedColors.value) return cachedColors.value
const getCssVar = (name, fallback) =>
getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback
cachedColors.value = {
primary: getCssVar('--primary', '#927CFF'),
success: getCssVar('--success', '#22C55E'),
warning: getCssVar('--warning', '#F59E0B'),
info: getCssVar('--info', '#38BDF8'),
textPrimary: getCssVar('--text-primary', '#F5F7FA'),
textSecondary: getCssVar('--text-secondary', '#A5AEBD'),
surface: getCssVar('--surface', '#181C25')
}
return cachedColors.value
}
async function fetchData() {
try {
const res = await axios.get('/api/proxies/latency-distribution')
if (res?.data?.ranges) {
updateChart(res.data)
}
} catch (err) {
console.error('Failed to fetch latency distribution:', err)
}
}
function updateChart(data) {
if (!chartInstance) return
const colors = cachedColors.value
const option = {
tooltip: {
trigger: 'axis',
confine: true,
axisPointer: {
type: 'shadow'
},
backgroundColor: 'rgba(24, 28, 37, 0.95)',
borderColor: colors.primary,
borderWidth: 1,
textStyle: {
color: colors.textPrimary,
fontSize: 13
},
formatter: (params) => {
const item = params[0]
return `${item.name}<br/>代理数: <b>${item.value}</b>`
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: '10%',
containLabel: true
},
xAxis: {
type: 'category',
data: data.ranges || [],
axisLabel: {
color: colors.textSecondary,
fontSize: 11,
rotate: 0
},
axisLine: {
lineStyle: {
color: colors.textSecondary
}
},
axisTick: {
show: false
}
},
yAxis: {
type: 'value',
name: '代理数',
nameTextStyle: {
color: colors.textSecondary,
fontSize: 11
},
axisLabel: {
color: colors.textSecondary,
fontSize: 11
},
axisLine: {
show: false
},
axisTick: {
show: false
},
splitLine: {
lineStyle: {
color: 'rgba(255,255,255,0.1)'
}
}
},
series: [
{
name: '延迟分布',
type: 'bar',
data: data.counts || [],
barWidth: '50%',
itemStyle: {
color: (params) => {
const colorList = [
colors.success,
colors.info,
colors.primary,
colors.warning,
colors.danger || '#EF4444'
]
return colorList[params.dataIndex] || colors.primary
},
borderRadius: [4, 4, 0, 0]
},
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowColor: 'rgba(146, 124, 255, 0.3)'
}
},
label: {
show: true,
position: 'top',
color: colors.textSecondary,
fontSize: 11,
formatter: '{c}'
}
}
],
animation: true,
animationDuration: 800,
animationEasing: 'cubicOut'
}
chartInstance.setOption(option, true)
}
function initChart() {
if (!chartRef.value) return
loadColors()
if (chartInstance) {
fetchData()
return
}
chartInstance = echarts.init(chartRef.value)
fetchData()
window.addEventListener('resize', handleResize)
}
function handleResize() {
if (resizeTimer) clearTimeout(resizeTimer)
resizeTimer = setTimeout(() => {
chartInstance?.resize()
}, 200)
}
function destroyChart() {
window.removeEventListener('resize', handleResize)
if (resizeTimer) {
clearTimeout(resizeTimer)
resizeTimer = null
}
chartInstance?.dispose()
chartInstance = null
}
onMounted(() => {
initChart()
})
onUnmounted(() => {
destroyChart()
})
watch(
() => chartInstance,
(val) => {
if (val) {
fetchData()
}
}
)
</script>
<style scoped>
.chart-card {
border-radius: var(--radius-lg);
min-height: 420px;
background: var(--surface);
border: 1px solid var(--border);
overflow: visible;
}
.chart-card:hover {
border-color: var(--border-light);
}
.header-icon {
margin-right: 8px;
color: var(--primary);
}
.help-icon {
color: var(--text-muted);
cursor: help;
transition: var(--transition-base);
}
.help-icon:hover {
color: var(--primary);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.chart-container {
height: 360px;
overflow: visible;
}
</style>
<style>
.chart-card .el-card__header {
border-bottom: none !important;
}
</style>

View File

@@ -124,6 +124,7 @@ function getChartOption() {
return {
tooltip: {
trigger: 'item',
confine: true,
formatter: (params) => {
const percent = total.value > 0 ? ((params.value / total.value) * 100).toFixed(1) : 0
return `${params.name}: ${params.value} (${percent}%)`
@@ -150,7 +151,7 @@ function getChartOption() {
{
type: 'pie',
radius: ['40%', '65%'],
center: ['38%', '50%'],
center: ['38%', '52%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 6,
@@ -252,17 +253,18 @@ onUnmounted(() => {
<style scoped>
.chart-card {
border-radius: var(--radius-lg);
min-height: 400px;
min-height: 420px;
background: var(--surface);
border: 1px solid var(--border);
overflow: visible;
}
.chart-card--compact {
min-height: 340px;
min-height: 420px;
}
.chart-card--compact .chart-container {
height: 300px;
height: 340px;
}
.chart-card:hover {
@@ -285,9 +287,16 @@ onUnmounted(() => {
}
.chart-container {
height: 350px;
height: 340px;
display: flex;
align-items: center;
justify-content: center;
overflow: visible;
}
</style>
<style>
.chart-card .el-card__header {
border-bottom: none !important;
}
</style>

View File

@@ -1,144 +0,0 @@
<template>
<el-card class="actions-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">
<el-icon class="header-icon"><Lightning /></el-icon>
快速操作
</span>
</div>
</template>
<div class="quick-actions">
<button
class="action-btn btn-success"
@click="$emit('export')"
>
<span class="btn-content">
<el-icon class="btn-icon"><Download /></el-icon>
<span class="btn-text">导出代理</span>
</span>
</button>
<button
class="action-btn btn-warning"
@click="$emit('clean')"
>
<span class="btn-content">
<el-icon class="btn-icon"><Delete /></el-icon>
<span class="btn-text">清理无效</span>
</span>
</button>
</div>
</el-card>
</template>
<script setup>
import { Download, Delete, Lightning } from '@element-plus/icons-vue'
defineEmits(['export', 'clean'])
</script>
<style scoped>
.actions-card {
border-radius: var(--radius-lg);
min-height: 400px;
background: var(--surface);
border: 1px solid var(--border);
}
.actions-card:hover {
border-color: var(--border-light);
}
.header-icon {
margin-right: 8px;
color: var(--primary);
}
.quick-actions {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
padding: 16px;
}
.action-btn {
width: 100%;
height: 56px;
border: none;
border-radius: var(--radius-md);
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: grid;
place-items: center;
}
.btn-content {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.btn-icon {
font-size: 18px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.btn-text {
display: inline-block;
text-align: center;
min-width: 64px;
}
/* 按钮样式 */
.btn-success {
background: var(--success);
color: #0F1117;
}
.btn-success:hover {
background: #2DD4BF;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(34, 197, 94, 0.4);
}
.btn-warning {
background: var(--warning);
color: #0F1117;
}
.btn-warning:hover {
background: #FBBF24;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.4);
}
@media (max-width: 768px) {
.quick-actions {
grid-template-columns: repeat(2, 1fr);
padding: 12px;
gap: 10px;
}
.action-btn {
height: 44px;
font-size: 14px;
}
.btn-text {
min-width: auto;
}
}
@media (max-width: 480px) {
.quick-actions {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,268 @@
<template>
<el-card class="chart-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">
<el-icon class="header-icon"><TrendCharts /></el-icon>
评分分布
</span>
<el-tooltip content="已验证可用代理的质量评分分布">
<el-icon class="help-icon"><InfoFilled /></el-icon>
</el-tooltip>
</div>
</template>
<div ref="chartRef" class="chart-container"></div>
</el-card>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { InfoFilled, TrendCharts } from '@element-plus/icons-vue'
import * as echarts from 'echarts'
import axios from '../api'
const chartRef = ref(null)
let chartInstance = null
let resizeTimer = null
const cachedColors = ref(null)
function loadColors() {
if (cachedColors.value) return cachedColors.value
const getCssVar = (name, fallback) =>
getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback
cachedColors.value = {
primary: getCssVar('--primary', '#927CFF'),
success: getCssVar('--success', '#22C55E'),
warning: getCssVar('--warning', '#F59E0B'),
info: getCssVar('--info', '#38BDF8'),
textPrimary: getCssVar('--text-primary', '#F5F7FA'),
textSecondary: getCssVar('--text-secondary', '#A5AEBD'),
surface: getCssVar('--surface', '#181C25')
}
return cachedColors.value
}
async function fetchData() {
try {
const res = await axios.get('/api/proxies/score-distribution')
if (res?.data?.ranges) {
updateChart(res.data)
}
} catch (err) {
console.error('Failed to fetch score distribution:', err)
}
}
function updateChart(data) {
if (!chartInstance) return
const colors = cachedColors.value
const option = {
tooltip: {
trigger: 'axis',
confine: true,
axisPointer: {
type: 'shadow'
},
backgroundColor: 'rgba(24, 28, 37, 0.95)',
borderColor: colors.primary,
borderWidth: 1,
textStyle: {
color: colors.textPrimary,
fontSize: 13
},
formatter: (params) => {
const item = params[0]
return `${item.name}<br/>代理数: <b>${item.value}</b>`
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: '10%',
containLabel: true
},
xAxis: {
type: 'category',
data: data.ranges || [],
axisLabel: {
color: colors.textSecondary,
fontSize: 11
},
axisLine: {
lineStyle: {
color: colors.textSecondary
}
},
axisTick: {
show: false
}
},
yAxis: {
type: 'value',
name: '代理数',
nameTextStyle: {
color: colors.textSecondary,
fontSize: 11
},
axisLabel: {
color: colors.textSecondary,
fontSize: 11
},
axisLine: {
show: false
},
axisTick: {
show: false
},
splitLine: {
lineStyle: {
color: 'rgba(255,255,255,0.1)'
}
}
},
series: [
{
name: '评分分布',
type: 'bar',
data: data.counts || [],
barWidth: '50%',
itemStyle: {
color: (params) => {
const colorList = [
colors.success,
colors.primary,
colors.info,
colors.warning,
colors.danger || '#EF4444'
]
return colorList[params.dataIndex] || colors.primary
},
borderRadius: [4, 4, 0, 0]
},
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowColor: 'rgba(146, 124, 255, 0.3)'
}
},
label: {
show: true,
position: 'top',
color: colors.textSecondary,
fontSize: 11,
formatter: '{c}'
}
}
],
animation: true,
animationDuration: 800,
animationEasing: 'cubicOut'
}
chartInstance.setOption(option, true)
}
function initChart() {
if (!chartRef.value) return
loadColors()
if (chartInstance) {
return
}
chartInstance = echarts.init(chartRef.value)
fetchData()
window.addEventListener('resize', handleResize)
}
function handleResize() {
if (resizeTimer) clearTimeout(resizeTimer)
resizeTimer = setTimeout(() => {
chartInstance?.resize()
}, 200)
}
function destroyChart() {
window.removeEventListener('resize', handleResize)
if (resizeTimer) {
clearTimeout(resizeTimer)
resizeTimer = null
}
chartInstance?.dispose()
chartInstance = null
}
onMounted(() => {
initChart()
})
onUnmounted(() => {
destroyChart()
})
watch(
() => chartInstance,
(val) => {
if (val) {
fetchData()
}
}
)
</script>
<style scoped>
.chart-card {
border-radius: var(--radius-lg);
min-height: 420px;
background: var(--surface);
border: 1px solid var(--border);
overflow: visible;
}
.chart-card:hover {
border-color: var(--border-light);
}
.header-icon {
margin-right: 8px;
color: var(--primary);
}
.help-icon {
color: var(--text-muted);
cursor: help;
transition: var(--transition-base);
}
.help-icon:hover {
color: var(--primary);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.chart-container {
height: 360px;
overflow: visible;
}
</style>
<style>
.chart-card .el-card__header {
border-bottom: none !important;
}
</style>

View File

@@ -0,0 +1,269 @@
<template>
<el-card class="chart-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">
<el-icon class="header-icon"><DataAnalysis /></el-icon>
验证成功率
</span>
<el-tooltip content="已验证可用代理占总验证代理的比例">
<el-icon class="help-icon"><InfoFilled /></el-icon>
</el-tooltip>
</div>
</template>
<div ref="chartRef" class="chart-container"></div>
</el-card>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
import { InfoFilled, DataAnalysis } from '@element-plus/icons-vue'
import * as echarts from 'echarts'
const props = defineProps({
data: {
type: Object,
default: () => ({})
}
})
const chartRef = ref(null)
let chartInstance = null
let resizeTimer = null
const cachedColors = ref(null)
const successRate = computed(() => {
const available = props.data?.available || 0
const invalid = props.data?.invalid_count || 0
const total = available + invalid
if (total === 0) return 0
return Math.round((available / total) * 100 * 10) / 10
})
function loadColors() {
if (cachedColors.value) return cachedColors.value
const getCssVar = (name, fallback) =>
getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback
cachedColors.value = {
primary: getCssVar('--primary', '#927CFF'),
success: getCssVar('--success', '#22C55E'),
warning: getCssVar('--warning', '#F59E0B'),
danger: getCssVar('--danger', '#EF4444'),
info: getCssVar('--info', '#38BDF8'),
textPrimary: getCssVar('--text-primary', '#F5F7FA'),
textSecondary: getCssVar('--text-secondary', '#A5AEBD'),
surface: getCssVar('--surface', '#181C25')
}
return cachedColors.value
}
function getChartOption() {
const colors = cachedColors.value
const rate = successRate.value
const getColor = (val) => {
if (val >= 80) return colors.success
if (val >= 50) return colors.warning
return colors.danger
}
return {
series: [
{
type: 'gauge',
startAngle: 200,
endAngle: -20,
radius: '90%',
center: ['50%', '60%'],
min: 0,
max: 100,
splitNumber: 5,
itemStyle: {
color: getColor(rate)
},
progress: {
show: true,
width: 18,
roundCap: true
},
pointer: {
show: true,
length: '60%',
width: 6,
itemStyle: {
color: colors.textSecondary
}
},
axisLine: {
lineStyle: {
width: 18,
color: [[1, colors.surface]]
},
roundCap: true
},
axisTick: {
show: true,
distance: -20,
length: 6,
lineStyle: {
color: colors.textSecondary,
width: 1
}
},
splitLine: {
show: true,
distance: -24,
length: 10,
lineStyle: {
color: colors.textSecondary,
width: 2
}
},
axisLabel: {
show: true,
distance: -35,
color: colors.textSecondary,
fontSize: 11,
formatter: '{value}%'
},
anchor: {
show: true,
size: 10,
itemStyle: {
color: colors.textSecondary,
borderWidth: 2,
borderColor: colors.surface
}
},
title: {
show: true,
offsetCenter: [0, '15%'],
fontSize: 12,
color: colors.textSecondary
},
detail: {
show: true,
offsetCenter: [0, '-10%'],
valueAnimation: true,
fontSize: 28,
fontWeight: 'bold',
color: getColor(rate),
formatter: '{value}%'
},
data: [
{
value: rate,
name: '验证通过率'
}
]
}
]
}
}
function initChart() {
if (!chartRef.value) return
loadColors()
if (chartInstance) {
updateChart()
return
}
chartInstance = echarts.init(chartRef.value)
updateChart()
window.addEventListener('resize', handleResize)
}
function updateChart() {
if (!chartInstance) return
chartInstance.setOption(getChartOption(), true)
}
function handleResize() {
if (resizeTimer) clearTimeout(resizeTimer)
resizeTimer = setTimeout(() => {
chartInstance?.resize()
}, 200)
}
function destroyChart() {
window.removeEventListener('resize', handleResize)
if (resizeTimer) {
clearTimeout(resizeTimer)
resizeTimer = null
}
chartInstance?.dispose()
chartInstance = null
}
watch(
() => props.data,
() => {
if (!chartInstance) {
initChart()
} else {
updateChart()
}
},
{ deep: true }
)
onMounted(() => {
initChart()
})
onUnmounted(() => {
destroyChart()
})
</script>
<style scoped>
.chart-card {
border-radius: var(--radius-lg);
min-height: 280px;
background: var(--surface);
border: 1px solid var(--border);
}
.chart-card:hover {
border-color: var(--border-light);
}
.header-icon {
margin-right: 8px;
color: var(--primary);
}
.help-icon {
color: var(--text-muted);
cursor: help;
transition: var(--transition-base);
}
.help-icon:hover {
color: var(--primary);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.chart-container {
height: 220px;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -47,25 +47,6 @@
/>
</div>
<el-row :gutter="20" class="charts-row">
<el-col :xs="24" :lg="16">
<el-row :gutter="16" class="charts-inner">
<el-col :xs="24" :md="12">
<ProtocolChart :data="stats" variant="available" compact />
</el-col>
<el-col :xs="24" :md="12">
<ProtocolChart :data="stats" variant="pending" compact />
</el-col>
</el-row>
</el-col>
<el-col :xs="24" :lg="8">
<QuickActions
@export="handleExport"
@clean="handleClean"
/>
</el-col>
</el-row>
<!-- 系统状态 -->
<el-row :gutter="20" class="status-row">
<el-col :xs="24">
@@ -76,6 +57,16 @@
<el-icon><InfoFilled /></el-icon>
系统状态
</span>
<div class="header-actions">
<el-button type="success" @click="handleExport">
<el-icon><Download /></el-icon>
导出代理
</el-button>
<el-button type="warning" @click="handleClean">
<el-icon><Delete /></el-icon>
清理无效
</el-button>
</div>
</div>
</template>
<div class="status-list">
@@ -105,6 +96,25 @@
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" class="charts-row">
<el-col :xs="24" :md="12">
<ProtocolChart :data="stats" variant="available" compact />
</el-col>
<el-col :xs="24" :md="12">
<ProtocolChart :data="stats" variant="pending" compact />
</el-col>
</el-row>
<!-- 延迟分布和评分分布 -->
<el-row :gutter="20" class="charts-row">
<el-col :xs="24" :md="12">
<LatencyHistogram />
</el-col>
<el-col :xs="24" :md="12">
<ScoreDistribution />
</el-col>
</el-row>
</div>
</template>
@@ -120,13 +130,16 @@ import {
InfoFilled,
Clock,
Odometer,
WarningFilled
WarningFilled,
Download,
Delete
} from '@element-plus/icons-vue'
import { useProxyStore } from '../stores/proxy'
import { formatNumber } from '../utils/format'
import StatCard from '../components/StatCard.vue'
import ProtocolChart from '../components/ProtocolChart.vue'
import QuickActions from '../components/QuickActions.vue'
import LatencyHistogram from '../components/LatencyHistogram.vue'
import ScoreDistribution from '../components/ScoreDistribution.vue'
import PageHeader from '../components/PageHeader.vue'
import { useStatsWebSocket } from '../composables/useStatsWebSocket'
@@ -143,7 +156,8 @@ const latencyLabel = computed(() => {
if (ms == null || ms === '' || Number(ms) <= 0) {
return '—'
}
return `${formatNumber(Number(ms), 1)} ms`
const seconds = Number(ms) / 1000
return `${formatNumber(seconds, 2)} s`
})
async function refreshData() {
@@ -220,6 +234,9 @@ onMounted(async () => {
.card-header {
font-size: 16px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: space-between;
}
.card-title {
@@ -228,6 +245,11 @@ onMounted(async () => {
gap: 8px;
}
.header-actions {
display: flex;
gap: 8px;
}
.status-list {
display: flex;
flex-wrap: wrap;
@@ -262,3 +284,9 @@ onMounted(async () => {
}
}
</style>
<style>
.status-card .el-card__header {
border-bottom: none !important;
}
</style>