主要变更: - 后端代码从根目录迁移到 app/ 目录 - 前端代码从 frontend/ 重命名为 WebUI/ - 更新所有导入路径以适配新结构 - 提取公共 API 响应函数到 app/api/common.py - 精简验证器服务代码 - 更新启动脚本和文档 测试: - 新增完整测试套件 (tests/) - 单元测试: 模型、仓库层 - 集成测试: 覆盖所有 22+ API 端点 - E2E 测试: 4个完整工作流场景 - 添加 pytest 配置和测试运行脚本
229 lines
5.6 KiB
Vue
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>
|