feat(dashboard): optimize dashboard layout and add new charts
This commit is contained in:
270
WebUI/src/components/LatencyHistogram.vue
Normal file
270
WebUI/src/components/LatencyHistogram.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
268
WebUI/src/components/ScoreDistribution.vue
Normal file
268
WebUI/src/components/ScoreDistribution.vue
Normal 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>
|
||||
269
WebUI/src/components/ValidationGauge.vue
Normal file
269
WebUI/src/components/ValidationGauge.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user