重构: 迁移后端代码到 app 目录,前端移动到 WebUI,添加完整测试套件

主要变更:
- 后端代码从根目录迁移到 app/ 目录
- 前端代码从 frontend/ 重命名为 WebUI/
- 更新所有导入路径以适配新结构
- 提取公共 API 响应函数到 app/api/common.py
- 精简验证器服务代码
- 更新启动脚本和文档

测试:
- 新增完整测试套件 (tests/)
- 单元测试: 模型、仓库层
- 集成测试: 覆盖所有 22+ API 端点
- E2E 测试: 4个完整工作流场景
- 添加 pytest 配置和测试运行脚本
This commit is contained in:
祀梦
2026-04-04 13:32:36 +08:00
parent df3cc87f88
commit 38bd66128b
109 changed files with 2017 additions and 548 deletions

View File

@@ -0,0 +1,81 @@
<template>
<el-card class="header-card" shadow="hover">
<h1 class="title">
<el-icon v-if="icon" :size="24" class="title-icon">
<component :is="icon" />
</el-icon>
<span class="title-text">{{ title }}</span>
</h1>
<p v-if="subtitle" class="subtitle">{{ subtitle }}</p>
</el-card>
</template>
<script setup>
/**
* 页面标题组件 - 冷灰紫主题
* @description 统一的页面头部展示
*/
defineProps({
/** 页面标题 */
title: {
type: String,
required: true
},
/** 图标组件或组件名称 */
icon: {
type: [String, Object],
default: null
},
/** 副标题 */
subtitle: {
type: String,
default: ''
}
})
</script>
<style scoped>
.header-card {
margin-bottom: 20px;
border-radius: var(--radius-lg);
background: var(--surface);
border: 1px solid var(--border);
}
.header-card:hover {
border-color: var(--border-light);
}
.title {
display: flex;
align-items: center;
gap: 10px;
margin: 0;
color: var(--text-primary);
font-size: 20px;
font-weight: 600;
letter-spacing: 0.5px;
}
.title-icon {
color: var(--primary);
flex-shrink: 0;
filter: drop-shadow(0 0 8px rgba(146, 124, 255, 0.3));
}
.title-text {
line-height: 1.2;
}
.subtitle {
margin: 8px 0 0;
color: var(--text-muted);
font-size: 14px;
}
@media (max-width: 768px) {
.title {
font-size: 18px;
}
}
</style>

View File

@@ -0,0 +1,228 @@
<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>

View File

@@ -0,0 +1,144 @@
<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,136 @@
<template>
<el-card :class="['stat-card', type]" shadow="hover">
<div class="stat-content">
<el-icon v-if="icon" class="stat-icon" :size="28">
<component :is="icon" />
</el-icon>
<div class="stat-info">
<div class="stat-value" :title="String(value)">{{ displayValue }}</div>
<div class="stat-label">{{ label }}</div>
</div>
</div>
</el-card>
</template>
<script setup>
import { computed } from 'vue'
/**
* 统计卡片组件 - 冷灰紫主题
* @description 用于展示 Dashboard 上的统计数据
*/
const props = defineProps({
/** 卡片类型,影响背景色 */
type: {
type: String,
default: 'default',
validator: (value) => ['default', 'total', 'available', 'new', 'score'].includes(value)
},
/** 图标组件 */
icon: {
type: [String, Object],
required: true
},
/** 数值 */
value: {
type: [Number, String],
required: true
},
/** 标签 */
label: {
type: String,
required: true
}
})
const displayValue = computed(() => {
const num = Number(props.value)
if (!isNaN(num) && num > 9999) {
return (num / 10000).toFixed(1) + 'w'
}
return props.value
})
</script>
<style scoped>
.stat-card {
border-radius: var(--radius-lg);
min-height: 100px;
background: var(--surface);
border: 1px solid var(--border);
transition: var(--transition-hover);
position: relative;
overflow: hidden;
}
.stat-card:hover {
border-color: var(--border-light);
transform: translateY(-2px);
}
/* 不同类型卡片的图标颜色区分 */
.stat-card.total .stat-icon {
color: var(--info);
filter: drop-shadow(0 0 8px rgba(56, 189, 248, 0.4));
}
.stat-card.available .stat-icon {
color: var(--success);
filter: drop-shadow(0 0 8px rgba(34, 197, 94, 0.4));
}
.stat-card.new .stat-icon {
color: var(--warning);
filter: drop-shadow(0 0 8px rgba(245, 158, 11, 0.4));
}
.stat-card.score .stat-icon {
color: var(--primary);
filter: drop-shadow(0 0 8px rgba(146, 124, 255, 0.4));
}
.stat-content {
display: flex;
align-items: center;
padding: 4px;
}
.stat-icon {
margin-right: 16px;
flex-shrink: 0;
color: var(--text-secondary);
}
.stat-info {
flex: 1;
min-width: 0;
}
.stat-value {
font-size: 26px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.stat-label {
font-size: 12px;
color: var(--text-muted);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
@media (max-width: 768px) {
.stat-value {
font-size: 22px;
}
.stat-icon {
margin-right: 12px;
}
}
</style>