Files
ProxyPool/WebUI/src/components/ProtocolChart.vue
祀梦 38bd66128b 重构: 迁移后端代码到 app 目录,前端移动到 WebUI,添加完整测试套件
主要变更:
- 后端代码从根目录迁移到 app/ 目录
- 前端代码从 frontend/ 重命名为 WebUI/
- 更新所有导入路径以适配新结构
- 提取公共 API 响应函数到 app/api/common.py
- 精简验证器服务代码
- 更新启动脚本和文档

测试:
- 新增完整测试套件 (tests/)
- 单元测试: 模型、仓库层
- 集成测试: 覆盖所有 22+ API 端点
- E2E 测试: 4个完整工作流场景
- 添加 pytest 配置和测试运行脚本
2026-04-04 13:32:36 +08:00

229 lines
5.6 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"><PieChart /></el-icon>
协议分布
</span>
<el-tooltip content="显示各协议类型的代理数量分布">
<el-icon class="help-icon"><InfoFilled /></el-icon>
</el-tooltip>
</div>
</template>
<div ref="chartRef" class="chart-container" v-loading="!hasData">
<el-empty v-if="!hasData" description="暂无数据" :image-size="80" />
</div>
</el-card>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
import { InfoFilled, PieChart } 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 hasData = computed(() => {
const { http_count, https_count, socks4_count, socks5_count } = props.data
return (http_count || 0) + (https_count || 0) + (socks4_count || 0) + (socks5_count || 0) > 0
})
const chartData = computed(() => {
if (!cachedColors.value) return []
const colors = cachedColors.value
return [
{ value: props.data.http_count || 0, name: 'HTTP', itemStyle: { color: colors.info } },
{ value: props.data.https_count || 0, name: 'HTTPS', itemStyle: { color: colors.success } },
{ value: props.data.socks4_count || 0, name: 'SOCKS4', itemStyle: { color: colors.primary } },
{ value: props.data.socks5_count || 0, name: 'SOCKS5', itemStyle: { color: colors.warning } }
].filter(item => item.value > 0)
})
const total = computed(() =>
chartData.value.reduce((sum, item) => sum + item.value, 0)
)
// ==================== 方法 ====================
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
}
function getChartOption() {
const colors = cachedColors.value
return {
tooltip: {
trigger: 'item',
formatter: (params) => {
const percent = total.value > 0 ? ((params.value / total.value) * 100).toFixed(1) : 0
return `${params.name}: ${params.value} (${percent}%)`
},
backgroundColor: 'rgba(24, 28, 37, 0.95)',
borderColor: colors.primary,
borderWidth: 1,
textStyle: {
color: colors.textPrimary,
fontSize: 14
}
},
legend: {
orient: 'vertical',
right: 10,
top: 'center',
textStyle: {
color: colors.textSecondary,
fontSize: 13
},
itemGap: 16
},
series: [
{
type: 'pie',
radius: ['40%', '65%'],
center: ['38%', '50%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 6,
borderColor: colors.surface,
borderWidth: 2
},
label: {
show: false
},
emphasis: {
label: {
show: true,
fontSize: 16,
fontWeight: 'bold',
color: colors.textPrimary,
formatter: '{b}\n{c}个'
},
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(146, 124, 255, 0.3)'
}
},
animationType: 'scale',
animationEasing: 'elasticOut',
animationDelay: (idx) => Math.random() * 200,
data: chartData.value
}
]
}
}
function initChart() {
if (!chartRef.value || !hasData.value) return
loadColors()
chartInstance = echarts.init(chartRef.value)
updateChart()
window.addEventListener('resize', handleResize)
}
function updateChart() {
if (!chartInstance || !hasData.value) 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 && hasData.value) {
initChart()
} else {
updateChart()
}
}, { deep: true })
// ==================== 生命周期 ====================
onMounted(() => {
if (hasData.value) {
initChart()
}
})
onUnmounted(() => {
destroyChart()
})
</script>
<style scoped>
.chart-card {
border-radius: var(--radius-lg);
min-height: 400px;
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);
}
.chart-container {
height: 350px;
display: flex;
align-items: center;
justify-content: center;
}
</style>