feat: JSON 配置、质量分与仪表盘,及设置与爬取流程

- 后端改为 config/app.json;pytest 使用 config/app.test.json 与 set_config_file,不再依赖环境变量;移除 pydantic-settings。

- 前端 API/WebSocket 由 config/webui.json 经 Vite define 注入。

- 代理分数按延迟与随机取用次数计算,新增 use_count 与 proxy_scoring;保存设置时同步调度器启停。

- 仪表盘双饼图(可用/待验证协议);设置页去掉调度器启停按钮并移动立即验证;爬取全部结束后自动提交全量验证。

- 删除 script/settings_maintain.py(此前已标记删除)。

Made-with: Cursor
This commit is contained in:
祀梦
2026-04-05 16:08:32 +08:00
parent 07248ff4ee
commit 7bc6d4e4de
31 changed files with 643 additions and 280 deletions

View File

@@ -1,14 +1,15 @@
import axios from 'axios'
import { showError } from '../utils/message'
/** @type {string} 默认 API 基础 URL */
export const DEFAULT_API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:18080'
/** @type {string} 由项目根目录 config/webui.json 注入(见 vite.config.js */
export const DEFAULT_API_BASE_URL =
typeof __WEBUI_API_BASE_URL__ !== 'undefined' ? __WEBUI_API_BASE_URL__ : 'http://127.0.0.1:18080'
/** @type {number} 请求超时时间(毫秒) */
export const REQUEST_TIMEOUT = 120000
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || DEFAULT_API_BASE_URL,
baseURL: DEFAULT_API_BASE_URL,
timeout: REQUEST_TIMEOUT
})

View File

@@ -1,18 +1,18 @@
<template>
<el-card class="chart-card" shadow="hover">
<el-card class="chart-card" :class="{ 'chart-card--compact': compact }" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">
<el-icon class="header-icon"><PieChart /></el-icon>
协议分布
{{ titleText }}
</span>
<el-tooltip content="显示各协议类型的代理数量分布">
<el-tooltip :content="helpText">
<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 ref="chartRef" class="chart-container">
<el-empty v-if="!hasData" :description="emptyText" :image-size="72" />
</div>
</el-card>
</template>
@@ -27,29 +27,73 @@ const props = defineProps({
data: {
type: Object,
default: () => ({})
},
/** available仅已验证可用pending仅待验证池 */
variant: {
type: String,
default: 'available',
validator: (v) => ['available', 'pending'].includes(v)
},
/** 并排展示时略压低高度 */
compact: {
type: Boolean,
default: false
}
})
const titleText = computed(() =>
props.variant === 'pending' ? '待验证 · 协议分布' : '可用代理 · 协议分布'
)
const helpText = computed(() =>
props.variant === 'pending'
? '仅统计 validated=0 的待验证代理,与各协议在队列中的占比'
: '仅统计已验证且分数大于 0 的可用代理,不含待验证与低分条目'
)
const emptyText = computed(() =>
props.variant === 'pending' ? '暂无待验证代理' : '暂无可用代理'
)
const chartRef = ref(null)
let chartInstance = null
let resizeTimer = null
const cachedColors = ref(null)
// ==================== 计算属性 ====================
const counts = computed(() => {
const d = props.data || {}
if (props.variant === 'pending') {
return {
http: d.pending_http_count || 0,
https: d.pending_https_count || 0,
socks4: d.pending_socks4_count || 0,
socks5: d.pending_socks5_count || 0
}
}
return {
http: d.http_count || 0,
https: d.https_count || 0,
socks4: d.socks4_count || 0,
socks5: d.socks5_count || 0
}
})
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 c = counts.value
return c.http + c.https + c.socks4 + c.socks5 > 0
})
const chartData = computed(() => {
if (!cachedColors.value) return []
const colors = cachedColors.value
const c = counts.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)
{ value: c.http, name: 'HTTP', itemStyle: { color: colors.info } },
{ value: c.https, name: 'HTTPS', itemStyle: { color: colors.success } },
{ value: c.socks4, name: 'SOCKS4', itemStyle: { color: colors.primary } },
{ value: c.socks5, name: 'SOCKS5', itemStyle: { color: colors.warning } }
].filter((item) => item.value > 0)
})
const total = computed(() =>
@@ -141,11 +185,16 @@ function getChartOption() {
function initChart() {
if (!chartRef.value || !hasData.value) return
loadColors()
if (chartInstance) {
updateChart()
return
}
chartInstance = echarts.init(chartRef.value)
updateChart()
window.addEventListener('resize', handleResize)
}
@@ -172,13 +221,21 @@ function destroyChart() {
}
// ==================== 监听 ====================
watch(() => props.data, () => {
if (!chartInstance && hasData.value) {
initChart()
} else {
updateChart()
}
}, { deep: true })
watch(
() => [props.data, props.variant, props.compact],
() => {
if (!hasData.value) {
destroyChart()
return
}
if (!chartInstance) {
initChart()
} else {
updateChart()
}
},
{ deep: true }
)
// ==================== 生命周期 ====================
onMounted(() => {
@@ -200,6 +257,14 @@ onUnmounted(() => {
border: 1px solid var(--border);
}
.chart-card--compact {
min-height: 340px;
}
.chart-card--compact .chart-container {
height: 300px;
}
.chart-card:hover {
border-color: var(--border-light);
}

View File

@@ -25,7 +25,16 @@ const props = defineProps({
type: String,
default: 'default',
validator: (value) =>
['default', 'total', 'pending', 'available', 'new', 'score'].includes(value)
[
'default',
'total',
'pending',
'available',
'new',
'score',
'invalid',
'latency'
].includes(value)
},
/** 图标组件 */
icon: {
@@ -45,6 +54,9 @@ const props = defineProps({
})
const displayValue = computed(() => {
if (props.value === '—' || props.value === '-') {
return props.value
}
const num = Number(props.value)
if (!isNaN(num) && num > 9999) {
return (num / 10000).toFixed(1) + 'w'
@@ -95,6 +107,16 @@ const displayValue = computed(() => {
filter: drop-shadow(0 0 8px rgba(146, 124, 255, 0.4));
}
.stat-card.invalid .stat-icon {
color: var(--danger, #f56c6c);
filter: drop-shadow(0 0 8px rgba(245, 108, 108, 0.35));
}
.stat-card.latency .stat-icon {
color: var(--info);
filter: drop-shadow(0 0 8px rgba(56, 189, 248, 0.35));
}
.stat-content {
display: flex;
align-items: center;

View File

@@ -9,12 +9,16 @@ const INITIAL_DELAY_MS = 1000
* @returns {string}
*/
export function resolveWebSocketStatsUrl() {
const explicit = import.meta.env.VITE_WS_URL
const explicit =
typeof __WEBUI_WS_URL__ !== 'undefined' ? String(__WEBUI_WS_URL__).trim() : ''
if (explicit) {
const t = String(explicit).trim().replace(/\/$/, '')
const t = explicit.replace(/\/$/, '')
return t.endsWith('/api/ws') ? t : `${t}/api/ws`
}
const api = import.meta.env.VITE_API_BASE_URL || 'http://localhost:18080'
const api =
typeof __WEBUI_API_BASE_URL__ !== 'undefined'
? __WEBUI_API_BASE_URL__
: 'http://127.0.0.1:18080'
const u = new URL(api)
u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:'
u.pathname = '/api/ws'

View File

@@ -31,13 +31,32 @@
type="score"
:icon="StarFilled"
:value="avgScore"
label="平均分数"
label="平均分数(可用)"
/>
<StatCard
type="latency"
:icon="Odometer"
:value="latencyLabel"
label="平均延迟(可用)"
/>
<StatCard
type="invalid"
:icon="WarningFilled"
:value="stats.invalid_count || 0"
label="低分待清理"
/>
</div>
<el-row :gutter="20" class="charts-row">
<el-col :xs="24" :lg="16">
<ProtocolChart :data="stats" />
<el-row :gutter="16" class="charts-inner">
<el-col :xs="24" :md="12">
<ProtocolChart :data="stats" variant="available" compact />
</el-col>
<el-col :xs="24" :md="12">
<ProtocolChart :data="stats" variant="pending" compact />
</el-col>
</el-row>
</el-col>
<el-col :xs="24" :lg="8">
<QuickActions
@@ -67,17 +86,21 @@
</el-tag>
</div>
<div class="status-item">
<span class="status-label">HTTP 代理</span>
<span class="status-label">HTTP可用</span>
<span class="status-value">{{ stats.http_count || 0 }}</span>
</div>
<div class="status-item">
<span class="status-label">HTTPS 代理</span>
<span class="status-label">HTTPS可用</span>
<span class="status-value">{{ stats.https_count || 0 }}</span>
</div>
<div class="status-item">
<span class="status-label">SOCKS 代理</span>
<span class="status-label">SOCKS可用</span>
<span class="status-value">{{ (stats.socks4_count || 0) + (stats.socks5_count || 0) }}</span>
</div>
<div class="status-item" v-if="(stats.invalid_count || 0) > 0">
<span class="status-label">低分可清理</span>
<span class="status-value warn">{{ stats.invalid_count }}</span>
</div>
</div>
</el-card>
</el-col>
@@ -95,7 +118,9 @@ import {
Timer,
StarFilled,
InfoFilled,
Clock
Clock,
Odometer,
WarningFilled
} from '@element-plus/icons-vue'
import { useProxyStore } from '../stores/proxy'
import { formatNumber } from '../utils/format'
@@ -113,6 +138,14 @@ const { start: startStatsWs } = useStatsWebSocket()
const stats = computed(() => proxyStore.stats)
const avgScore = computed(() => formatNumber(stats.value.avg_score || 0, 1))
const latencyLabel = computed(() => {
const ms = stats.value.avg_response_ms
if (ms == null || ms === '' || Number(ms) <= 0) {
return '—'
}
return `${formatNumber(Number(ms), 1)} ms`
})
async function refreshData() {
await proxyStore.fetchStats()
}
@@ -170,6 +203,10 @@ onMounted(async () => {
margin-bottom: 20px;
}
.charts-inner {
height: 100%;
}
.status-row {
margin-bottom: 20px;
}
@@ -214,6 +251,10 @@ onMounted(async () => {
color: var(--primary);
}
.status-value.warn {
color: var(--danger, #f56c6c);
}
@media (max-width: 768px) {
.status-list {
flex-direction: column;

View File

@@ -359,7 +359,7 @@ async function handleCrawlAll() {
}
await ElMessageBox.confirm(
`确定要运行所有 ${enabledPlugins.length} 个启用的插件吗?代理将先以「待验证」入库,需再执行「全部验证」后才会变为可用(除非已开启「爬取后立即验证」)。`,
`确定要运行所有 ${enabledPlugins.length} 个启用的插件吗?代理将先以「待验证」入库;全部插件爬取结束后会自动执行一次「全部验证」(若已开启「爬取后立即验证」,新入库条目也会在爬取时提前排队验证)。`,
'批量爬取确认',
{
confirmButtonText: '开始爬取',
@@ -405,7 +405,11 @@ async function handleCrawlAll() {
crawlResults.value = merged
}
if (!data.cancelled) {
ElMessage.success('批量爬取完成')
ElMessage.success(
data.validate_all_task_id
? '批量爬取完成,已自动启动全部验证'
: '批量爬取完成'
)
}
await pluginsStore.fetchPlugins()
} else {

View File

@@ -113,9 +113,21 @@
</el-tag>
</template>
</el-table-column>
<el-table-column prop="use_count" label="取用次数" width="100">
<template #default="{ row }">
{{ row.use_count ?? 0 }}
</template>
</el-table-column>
<el-table-column prop="score" label="分数" width="100">
<template #default="{ row }">
<span class="score-value" :class="{ 'score-high': row.score >= 8, 'score-medium': row.score >= 5 && row.score < 8, 'score-low': row.score < 5 }">
<span
class="score-value"
:class="{
'score-high': row.score >= 70,
'score-medium': row.score >= 40 && row.score < 70,
'score-low': row.score < 40
}"
>
{{ row.score || 0 }}
</span>
</template>

View File

@@ -2,7 +2,7 @@
<div class="page-container">
<PageHeader title="系统设置" :icon="Setting" />
<!-- 验证调度器控制 -->
<!-- 验证调度器状态启停由下方启用自动验证+ 保存配置 -->
<el-card class="settings-card scheduler-card" shadow="hover">
<template #header>
<div class="card-header">
@@ -17,37 +17,6 @@
</div>
</template>
<div class="scheduler-actions">
<el-button
type="success"
@click="handleStartScheduler"
:disabled="schedulerRunning"
:loading="schedulerLoading"
>
<el-icon class="btn-icon"><VideoPlay /></el-icon>
启动自动验证
</el-button>
<el-button
type="danger"
@click="handleStopScheduler"
:disabled="!schedulerRunning"
:loading="schedulerLoading"
>
<el-icon class="btn-icon"><VideoPause /></el-icon>
停止自动验证
</el-button>
<el-button
type="primary"
@click="handleValidateNow"
:loading="validating"
>
<el-icon class="btn-icon"><Refresh /></el-icon>
立即验证全部
</el-button>
</div>
<div class="scheduler-info">
<el-alert
:title="schedulerInfo"
@@ -66,15 +35,25 @@
<el-icon class="header-icon"><Tools /></el-icon>
基础配置
</span>
<el-button
type="primary"
@click="handleSave"
size="large"
:loading="saving"
>
<el-icon class="btn-icon"><DocumentChecked /></el-icon>
保存配置
</el-button>
<div class="header-actions">
<el-button
size="large"
@click="handleValidateNow"
:loading="validating"
>
<el-icon class="btn-icon"><Refresh /></el-icon>
立即验证全部
</el-button>
<el-button
type="primary"
@click="handleSave"
size="large"
:loading="saving"
>
<el-icon class="btn-icon"><DocumentChecked /></el-icon>
保存配置
</el-button>
</div>
</div>
</template>
@@ -199,8 +178,6 @@ import {
DocumentChecked,
Tools,
Timer,
VideoPlay,
VideoPause,
Refresh
} from '@element-plus/icons-vue'
import { settingService } from '../services/settingService'
@@ -210,11 +187,8 @@ import PageHeader from '../components/PageHeader.vue'
// ==================== Composables ====================
const {
schedulerRunning,
schedulerLoading,
validating,
fetchStatus,
startScheduler,
stopScheduler,
validateNow
} = useScheduler()
@@ -248,7 +222,7 @@ const schedulerInfo = computed(() => {
if (schedulerRunning.value) {
return `验证调度器正在运行,每 ${settings.validate_interval_minutes} 分钟执行一次:优先验证待验证代理,再按检查时间复检已入库代理`
}
return '验证调度器已停止,待验证代理不会自动检查;可在下方开启自动验证或点击「立即验证全部」'
return '验证调度器当前未运行。请在下方打开「启用自动验证」并保存配置以恢复定时任务;需要时可使用「基础配置」标题栏中的「立即验证全部」手动执行一轮全量验证。'
})
// ==================== 表单验证规则 ====================
@@ -276,21 +250,6 @@ async function fetchSettings() {
}
}
// ==================== 调度器控制 ====================
async function handleStartScheduler() {
await startScheduler(
(msg) => ElMessage.success(msg),
(msg) => ElMessage.error(msg)
)
}
async function handleStopScheduler() {
await stopScheduler(
(msg) => ElMessage.success(msg),
(msg) => ElMessage.error(msg)
)
}
async function handleValidateNow() {
try {
await ElMessageBox.confirm(
@@ -372,6 +331,13 @@ onMounted(() => {
align-items: center;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.card-title {
font-size: 16px;
font-weight: 600;
@@ -403,15 +369,8 @@ onMounted(() => {
color: var(--text-secondary);
}
.scheduler-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.scheduler-info {
margin-top: 8px;
margin-top: 0;
}
.settings-form {

3
WebUI/src/vite-globals.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
/** 由 vite.config.js define 注入(值来自项目根目录 config/webui.json */
declare const __WEBUI_API_BASE_URL__: string
declare const __WEBUI_WS_URL__: string

View File

@@ -1,9 +1,25 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const webuiConfigPath = path.resolve(__dirname, '../config/webui.json')
let webui = { api_base_url: 'http://127.0.0.1:18080', ws_url: '' }
try {
webui = { ...webui, ...JSON.parse(fs.readFileSync(webuiConfigPath, 'utf-8')) }
} catch {
console.warn('[vite] 未读取 config/webui.json使用默认 API 地址')
}
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
define: {
__WEBUI_API_BASE_URL__: JSON.stringify(String(webui.api_base_url || '').trim() || 'http://127.0.0.1:18080'),
__WEBUI_WS_URL__: JSON.stringify(webui.ws_url != null ? String(webui.ws_url) : ''),
},
server: {
port: 18081,
// 支持 Vue Router 的 history 模式