重构: 迁移后端代码到 app 目录,前端移动到 WebUI,添加完整测试套件
主要变更: - 后端代码从根目录迁移到 app/ 目录 - 前端代码从 frontend/ 重命名为 WebUI/ - 更新所有导入路径以适配新结构 - 提取公共 API 响应函数到 app/api/common.py - 精简验证器服务代码 - 更新启动脚本和文档 测试: - 新增完整测试套件 (tests/) - 单元测试: 模型、仓库层 - 集成测试: 覆盖所有 22+ API 端点 - E2E 测试: 4个完整工作流场景 - 添加 pytest 配置和测试运行脚本
This commit is contained in:
81
WebUI/src/components/PageHeader.vue
Normal file
81
WebUI/src/components/PageHeader.vue
Normal 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>
|
||||
228
WebUI/src/components/ProtocolChart.vue
Normal file
228
WebUI/src/components/ProtocolChart.vue
Normal 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>
|
||||
144
WebUI/src/components/QuickActions.vue
Normal file
144
WebUI/src/components/QuickActions.vue
Normal 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>
|
||||
136
WebUI/src/components/StatCard.vue
Normal file
136
WebUI/src/components/StatCard.vue
Normal 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>
|
||||
Reference in New Issue
Block a user