269 lines
5.5 KiB
Vue
269 lines
5.5 KiB
Vue
<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>
|