feat: fpw plugins, validation/crawl perf, WS stats, test DB isolation

- Add Free_Proxy_Website-style fpw_* plugins and register them
- Per-plugin crawl timeout (crawl_timeout_seconds=120); remove global crawl_timeout setting
- Validator: fix connect vs total timeout on save; SOCKS session LRU cache; drop redundant semaphore
- Validation handler uses single DB connection; batch upsert after crawl; WorkerPool put_nowait
- Remove unused max_retries from settings API/UI; settings maintenance SQL + init_db cleanup of deprecated keys
- WebSocket dashboard stats; ProxyList pool_filter and API alignment
- POST /api/proxies/delete-one for IPv6-safe deletes; task poll stops on 404
- pytest uses PROXYPOOL_DB_PATH=db/proxies.test.sqlite so tests do not wipe production DB
- .gitignore: explicit proxies.test.sqlite patterns; fix plugin_service ValidationException import

Made-with: Cursor
This commit is contained in:
祀梦
2026-04-05 13:39:19 +08:00
parent 92c7fa19e2
commit 0131c8b408
63 changed files with 2331 additions and 531 deletions

View File

@@ -64,7 +64,8 @@ export const proxiesAPI = {
getProxies: (params, signal) =>
api.post('/api/proxies', cleanParams(params), { signal }),
deleteProxy: (ip, port) => api.delete(`/api/proxies/${ip}/${port}`),
deleteProxy: (ip, port) =>
api.post('/api/proxies/delete-one', { ip, port }),
batchDeleteProxies: (proxies) => api.post('/api/proxies/batch-delete', { proxies }),

View File

@@ -24,7 +24,8 @@ const props = defineProps({
type: {
type: String,
default: 'default',
validator: (value) => ['default', 'total', 'available', 'new', 'score'].includes(value)
validator: (value) =>
['default', 'total', 'pending', 'available', 'new', 'score'].includes(value)
},
/** 图标组件 */
icon: {
@@ -79,6 +80,11 @@ const displayValue = computed(() => {
filter: drop-shadow(0 0 8px rgba(34, 197, 94, 0.4));
}
.stat-card.pending .stat-icon {
color: var(--warning);
filter: drop-shadow(0 0 8px rgba(250, 204, 21, 0.45));
}
.stat-card.new .stat-icon {
color: var(--warning);
filter: drop-shadow(0 0 8px rgba(245, 158, 11, 0.4));

View File

@@ -0,0 +1,134 @@
import { onUnmounted } from 'vue'
import { useProxyStore } from '../stores/proxy'
const MAX_DELAY_MS = 30000
const INITIAL_DELAY_MS = 1000
/**
* 由 API Base 推导统计 WebSocket URL/api/ws
* @returns {string}
*/
export function resolveWebSocketStatsUrl() {
const explicit = import.meta.env.VITE_WS_URL
if (explicit) {
const t = String(explicit).trim().replace(/\/$/, '')
return t.endsWith('/api/ws') ? t : `${t}/api/ws`
}
const api = import.meta.env.VITE_API_BASE_URL || 'http://localhost:18080'
const u = new URL(api)
u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:'
u.pathname = '/api/ws'
u.search = ''
u.hash = ''
return u.toString()
}
/**
* 连接后端 WebSocket 接收实时统计;指数退避重连;页签隐藏时暂停连接。
*/
export function useStatsWebSocket() {
const store = useProxyStore()
let ws = null
let reconnectTimer = null
let attempt = 0
let stopped = false
let paused = false
function backoffDelayMs() {
return Math.min(INITIAL_DELAY_MS * 2 ** attempt, MAX_DELAY_MS)
}
function clearReconnectTimer() {
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
}
function connect() {
if (stopped || paused) return
clearReconnectTimer()
const url = resolveWebSocketStatsUrl()
ws = new WebSocket(url)
ws.onopen = () => {
attempt = 0
}
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data)
if (msg.type === 'stats' && msg.data) {
store.applyStats(msg.data)
} else if (msg.type === 'pong') {
// optional heartbeat
}
} catch {
// ignore malformed
}
}
ws.onclose = () => {
ws = null
if (stopped || paused) return
attempt += 1
reconnectTimer = setTimeout(connect, backoffDelayMs())
}
ws.onerror = () => {
try {
ws?.close()
} catch {
// ignore
}
}
}
function handleVisibility() {
if (document.hidden) {
paused = true
clearReconnectTimer()
if (ws) {
const s = ws
ws = null
s.onclose = null
try {
s.close()
} catch {
// ignore
}
}
} else {
paused = false
if (!stopped) {
attempt = 0
connect()
}
}
}
function start() {
stopped = false
paused = false
attempt = 0
document.addEventListener('visibilitychange', handleVisibility)
connect()
}
function disconnect() {
stopped = true
paused = false
document.removeEventListener('visibilitychange', handleVisibility)
clearReconnectTimer()
if (ws) {
const s = ws
ws = null
s.onclose = null
try {
s.close()
} catch {
// ignore
}
}
}
onUnmounted(disconnect)
return { start, disconnect }
}

View File

@@ -1,7 +1,8 @@
import { tasksAPI } from '../api'
const POLL_INTERVAL = 1000
const MAX_POLL_ATTEMPTS = 30
/** 大批量爬取可能超过 30s适当放宽避免误报「任务进行中」 */
const MAX_POLL_ATTEMPTS = 300
/**
* 轮询任务状态直到完成或失败
@@ -21,7 +22,14 @@ export async function pollTaskStatus(taskId) {
return response
}
} catch (error) {
// 网络异常时继续轮询,不中断
const status = error.response?.status
if (status === 404) {
return {
code: 404,
message: error.response?.data?.message || '任务不存在',
data: { task_id: taskId, status: 'failed', error: 'not_found' }
}
}
console.warn('轮询任务状态失败:', error)
}
}

View File

@@ -32,6 +32,12 @@ export const useProxyStore = defineStore('proxy', () => {
* 获取统计信息
* @returns {Promise<boolean>}
*/
function applyStats(data) {
if (data && typeof data === 'object') {
stats.value = { ...data }
}
}
async function fetchStats() {
try {
const response = await proxyService.getStats()
@@ -174,6 +180,7 @@ export const useProxyStore = defineStore('proxy', () => {
isEmpty,
// Actions
fetchStats,
applyStats,
fetchProxies,
deleteProxy,
batchDeleteProxies,

View File

@@ -2,40 +2,38 @@
<div class="page-container">
<PageHeader title="代理池管理系统" :icon="MagicStick" />
<el-row :gutter="20" class="stats-row">
<el-col :xs="24" :sm="12" :md="12" :lg="6" :xl="6">
<StatCard
type="total"
:icon="DataLine"
:value="stats.total || 0"
label="总代理数"
/>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="6" :xl="6">
<StatCard
type="available"
:icon="CircleCheck"
:value="stats.available || 0"
label="可用数量"
/>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="6" :xl="6">
<StatCard
type="new"
:icon="Timer"
:value="stats.today_new || 0"
label="今日新增"
/>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="6" :xl="6">
<StatCard
type="score"
:icon="StarFilled"
:value="avgScore"
label="平均分数"
/>
</el-col>
</el-row>
<div class="stats-grid">
<StatCard
type="total"
:icon="DataLine"
:value="stats.total || 0"
label="总代理数"
/>
<StatCard
type="pending"
:icon="Clock"
:value="stats.pending || 0"
label="待验证"
/>
<StatCard
type="available"
:icon="CircleCheck"
:value="stats.available || 0"
label="可用数量"
/>
<StatCard
type="new"
:icon="Timer"
:value="stats.today_new || 0"
label="今日新增"
/>
<StatCard
type="score"
:icon="StarFilled"
:value="avgScore"
label="平均分数"
/>
</div>
<el-row :gutter="20" class="charts-row">
<el-col :xs="24" :lg="16">
@@ -88,7 +86,7 @@
</template>
<script setup>
import { computed, onMounted, onUnmounted } from 'vue'
import { computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
MagicStick,
@@ -96,7 +94,8 @@ import {
CircleCheck,
Timer,
StarFilled,
InfoFilled
InfoFilled,
Clock
} from '@element-plus/icons-vue'
import { useProxyStore } from '../stores/proxy'
import { formatNumber } from '../utils/format'
@@ -104,26 +103,16 @@ import StatCard from '../components/StatCard.vue'
import ProtocolChart from '../components/ProtocolChart.vue'
import QuickActions from '../components/QuickActions.vue'
import PageHeader from '../components/PageHeader.vue'
import { useStatsWebSocket } from '../composables/useStatsWebSocket'
// ==================== Store ====================
const proxyStore = useProxyStore()
const { start: startStatsWs } = useStatsWebSocket()
// ==================== 计算属性 ====================
const stats = computed(() => proxyStore.stats)
const avgScore = computed(() => formatNumber(stats.value.avg_score || 0, 1))
// ==================== 定时刷新 ====================
const REFRESH_INTERVAL = 5000
let refreshTimer = null
let isPageVisible = true
function handleVisibilityChange() {
isPageVisible = !document.hidden
if (isPageVisible) {
refreshData()
}
}
async function refreshData() {
await proxyStore.fetchStats()
}
@@ -165,26 +154,15 @@ async function handleClean() {
// ==================== 生命周期 ====================
onMounted(async () => {
await refreshData()
document.addEventListener('visibilitychange', handleVisibilityChange)
refreshTimer = setInterval(() => {
if (isPageVisible) {
refreshData()
}
}, REFRESH_INTERVAL)
})
onUnmounted(() => {
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
document.removeEventListener('visibilitychange', handleVisibilityChange)
startStatsWs()
})
</script>
<style scoped>
.stats-row {
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
@@ -237,14 +215,6 @@ onUnmounted(() => {
}
@media (max-width: 768px) {
.stats-row .el-col {
margin-bottom: 16px;
}
.stats-row .el-col:last-child {
margin-bottom: 0;
}
.status-list {
flex-direction: column;
gap: 16px;

View File

@@ -15,7 +15,7 @@
</el-tag>
</div>
<div class="header-actions">
<el-button type="success" @click="handleCrawlAll" size="large" :loading="crawlingAll">
<el-button type="success" @click="handleCrawlAll" size="large" :loading="crawlAllMask">
<el-icon class="btn-icon"><Promotion /></el-icon>
全部爬取
</el-button>
@@ -53,12 +53,12 @@
</template>
</el-table-column>
<el-table-column label="统计" width="180">
<el-table-column label="上次爬取" width="200">
<template #default="{ row }">
<div class="plugin-stats">
<div class="plugin-stats" title="绿色为最近一轮爬到的代理条数红色为最近一轮是否失败0 成功 / 1 失败),不是验证通过数">
<div class="stat-item">
<el-icon class="stat-icon success"><CircleCheck /></el-icon>
<span class="stat-value success">{{ row.success_count || 0 }}</span>
<span class="stat-value success">{{ row.success_count || 0 }} </span>
</div>
<div class="stat-item">
<el-icon class="stat-icon failed"><CircleClose /></el-icon>
@@ -74,7 +74,35 @@
</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right" align="center">
<el-table-column label="最近爬取" min-width="340" align="left">
<template #default="{ row }">
<div v-if="crawlAllMask && row.enabled" class="crawl-running-row">
<el-icon class="is-loading crawl-spin"><Loading /></el-icon>
<span>正在爬取</span>
</div>
<div v-else-if="crawlResults[row.id]" class="result-panel" :class="crawlResults[row.id].type">
<div class="result-panel-head">
<el-icon v-if="crawlResults[row.id].type === 'success'" class="result-head-icon success"><CircleCheck /></el-icon>
<el-icon v-else class="result-head-icon failed"><CircleClose /></el-icon>
<span class="result-panel-title">{{ crawlResults[row.id].message }}</span>
<el-icon class="result-close" @click="clearCrawlResult(row.id)"><Close /></el-icon>
</div>
<div class="result-panel-body">
<template v-if="crawlResults[row.id].data && crawlResults[row.id].data.proxy_count !== undefined">
<span class="result-pill fetched">爬取 {{ crawlResults[row.id].data.proxy_count }} </span>
</template>
<template v-if="crawlResults[row.id].data?.crawl_failed">
<div class="result-error-block" :title="crawlResults[row.id].data.error || ''">
{{ crawlResults[row.id].data.error || '爬取失败' }}
</div>
</template>
</div>
</div>
<span v-else class="result-placeholder"></span>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right" align="center">
<template #default="{ row }">
<div class="plugin-actions">
<el-button
@@ -89,27 +117,13 @@
type="success"
size="small"
@click="handleCrawl(row.id)"
:loading="crawlingPlugins.has(row.id)"
:loading="crawlingPlugins.has(row.id) || (crawlAllMask && row.enabled)"
:disabled="!row.enabled"
>
<el-icon class="btn-icon"><Promotion /></el-icon>
爬取
</el-button>
</div>
<div v-if="crawlResults[row.id]" class="plugin-crawl-result">
<div class="result-mini" :class="crawlResults[row.id].type">
<el-icon v-if="crawlResults[row.id].type === 'success'" class="result-icon success"><CircleCheck /></el-icon>
<el-icon v-else class="result-icon failed"><CircleClose /></el-icon>
<span class="result-text">{{ crawlResults[row.id].message }}</span>
<span v-if="crawlResults[row.id].data?.success_count !== undefined" class="result-count valid">
有效 {{ crawlResults[row.id].data.success_count }}
</span>
<span v-if="crawlResults[row.id].data?.failure_count !== undefined" class="result-count invalid">
无效 {{ crawlResults[row.id].data.failure_count }}
</span>
<el-icon class="result-close" @click="clearCrawlResult(row.id)"><Close /></el-icon>
</div>
</div>
</template>
</el-table-column>
</el-table>
@@ -130,18 +144,37 @@
@close="allCrawlResult = null"
>
<template v-if="allCrawlResult.data">
<div class="crawl-stats">
<div class="crawl-stats crawl-stats-summary">
<span v-if="allCrawlResult.data.total_crawled !== undefined">
爬取: {{ allCrawlResult.data.total_crawled }}
合计爬取: <strong>{{ allCrawlResult.data.total_crawled }}</strong>
</span>
<span v-if="allCrawlResult.data.valid_count !== undefined" class="valid-count">
有效: {{ allCrawlResult.data.valid_count }}
</span>
<span v-if="allCrawlResult.data.invalid_count !== undefined" class="invalid-count">
无效: {{ allCrawlResult.data.invalid_count }}
<span
v-if="allCrawlResult.data.plugins_failed !== undefined"
class="invalid-count"
>
失败插件: <strong>{{ allCrawlResult.data.plugins_failed }}</strong>
</span>
</div>
<ul
v-if="allCrawlResult.data.per_plugin?.length"
class="per-plugin-breakdown"
>
<li
v-for="(item, idx) in allCrawlResult.data.per_plugin"
:key="item.plugin_id || `pp-${idx}`"
class="per-plugin-line"
>
<span class="pp-name">{{ pluginDisplayName(item.plugin_id) }}</span>
<template v-if="item.crawl_failed">
<el-tag type="danger" size="small" effect="light">失败</el-tag>
<span class="pp-detail err">{{ item.error || '未知错误' }}</span>
</template>
<template v-else>
<el-tag type="success" size="small" effect="light">完成</el-tag>
<span class="pp-detail">爬取 <strong>{{ item.proxy_count }}</strong> </span>
</template>
</li>
</ul>
</template>
</el-alert>
</el-card>
@@ -198,7 +231,8 @@ import {
CircleClose,
Box,
Setting,
Close
Close,
Loading
} from '@element-plus/icons-vue'
import { usePluginsStore } from '../stores/plugins'
import { pluginService } from '../services/pluginService'
@@ -207,10 +241,17 @@ import PageHeader from '../components/PageHeader.vue'
const pluginsStore = usePluginsStore()
const crawlingPlugins = ref(new Set())
const crawlingAll = ref(false)
/** 全部爬取进行中:各启用插件行显示「正在爬取」与按钮 loading */
const crawlAllMask = ref(false)
const crawlResults = ref({})
const allCrawlResult = ref(null)
function pluginDisplayName(pluginId) {
if (!pluginId) return '(未知插件)'
const p = pluginsStore.plugins.find((x) => x.id === pluginId)
return p?.name || pluginId
}
// 配置对话框
const configDialogVisible = ref(false)
const currentPlugin = ref(null)
@@ -273,21 +314,30 @@ async function handleCrawl(pluginId) {
const response = await pluginService.crawlPlugin(pluginId)
if (response.code === 200) {
crawlResults.value[pluginId] = {
type: 'success',
message: response.message,
data: response.data
crawlResults.value = {
...crawlResults.value,
[pluginId]: {
type: 'success',
message: response.message,
data: response.data
}
}
} else {
crawlResults.value[pluginId] = {
type: 'error',
message: response.message || '爬取失败'
crawlResults.value = {
...crawlResults.value,
[pluginId]: {
type: 'error',
message: response.message || '爬取失败'
}
}
}
} catch (error) {
crawlResults.value[pluginId] = {
type: 'error',
message: '爬取过程出错'
crawlResults.value = {
...crawlResults.value,
[pluginId]: {
type: 'error',
message: '爬取过程出错'
}
}
} finally {
crawlingPlugins.value.delete(pluginId)
@@ -295,7 +345,9 @@ async function handleCrawl(pluginId) {
}
function clearCrawlResult(pluginId) {
delete crawlResults.value[pluginId]
const next = { ...crawlResults.value }
delete next[pluginId]
crawlResults.value = next
}
async function handleCrawlAll() {
@@ -307,7 +359,7 @@ async function handleCrawlAll() {
}
await ElMessageBox.confirm(
`确定要运行所有 ${enabledPlugins.length} 个启用的插件吗?这将爬取并验证所有代理。`,
`确定要运行所有 ${enabledPlugins.length} 个启用的插件吗?代理将先以「待验证」入库,需再执行「全部验证」后才会变为可用(除非已开启「爬取后立即验证」)`,
'批量爬取确认',
{
confirmButtonText: '开始爬取',
@@ -315,21 +367,47 @@ async function handleCrawlAll() {
type: 'info'
}
)
crawlingAll.value = true
allCrawlResult.value = null
const response = await pluginService.crawlAll()
if (response.code === 200) {
allCrawlResult.value = {
type: response.data?.cancelled ? 'info' : 'success',
message: response.message,
data: response.data
{
const cleared = { ...crawlResults.value }
for (const p of enabledPlugins) {
delete cleared[p.id]
}
if (!response.data?.cancelled) {
crawlResults.value = cleared
}
crawlAllMask.value = true
const response = await pluginService.crawlAll()
if (response.code === 200) {
const data = response.data || {}
allCrawlResult.value = {
type: data.cancelled ? 'info' : 'success',
message: response.message,
data
}
if (Array.isArray(data.per_plugin) && data.per_plugin.length) {
const merged = { ...crawlResults.value }
for (const item of data.per_plugin) {
if (!item.plugin_id) continue
merged[item.plugin_id] = {
type: item.crawl_failed ? 'error' : 'success',
message: '获取任务状态成功',
data: {
proxy_count: item.proxy_count,
crawl_failed: item.crawl_failed,
error: item.error
}
}
}
crawlResults.value = merged
}
if (!data.cancelled) {
ElMessage.success('批量爬取完成')
}
await pluginsStore.fetchPlugins()
} else {
allCrawlResult.value = {
type: 'error',
@@ -345,7 +423,7 @@ async function handleCrawlAll() {
}
}
} finally {
crawlingAll.value = false
crawlAllMask.value = false
}
}
@@ -487,66 +565,167 @@ onMounted(async () => {
.plugin-actions {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 8px;
}
.plugin-crawl-result {
margin-top: 8px;
}
.result-mini {
display: inline-flex;
.crawl-running-row {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
line-height: 1.4;
gap: 8px;
padding: 10px 12px;
font-size: 14px;
color: var(--primary);
background: var(--surface-2);
border-radius: var(--radius-md, 8px);
border: 1px solid var(--border);
}
.result-mini.success {
background: rgba(103, 194, 58, 0.15);
.crawl-spin {
font-size: 18px;
animation: plugin-crawl-spin 1s linear infinite;
}
@keyframes plugin-crawl-spin {
to {
transform: rotate(360deg);
}
}
.result-placeholder {
color: var(--text-muted);
font-size: 14px;
}
.result-panel {
padding: 12px 14px;
border-radius: var(--radius-md, 8px);
border: 1px solid var(--border);
background: var(--surface-2);
min-height: 72px;
}
.result-panel.success {
border-color: rgba(103, 194, 58, 0.35);
}
.result-panel.error {
border-color: rgba(245, 108, 108, 0.35);
}
.result-panel-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.result-head-icon {
font-size: 18px;
flex-shrink: 0;
}
.result-head-icon.success {
color: var(--success);
}
.result-mini.error {
background: rgba(245, 108, 108, 0.15);
.result-head-icon.failed {
color: var(--danger);
}
.result-icon {
.result-panel-title {
flex: 1;
font-size: 13px;
}
.result-text {
font-weight: 500;
}
.result-count {
font-weight: 600;
padding: 0 4px;
border-radius: 3px;
color: var(--text-secondary);
line-height: 1.4;
}
.result-count.valid {
.result-panel-body {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-start;
}
.result-pill {
display: inline-block;
padding: 4px 12px;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
}
.result-pill.fetched {
background: rgba(103, 194, 58, 0.2);
color: var(--success);
}
.result-count.invalid {
background: rgba(245, 108, 108, 0.2);
.result-error-block {
font-size: 13px;
line-height: 1.5;
color: var(--danger);
word-break: break-word;
white-space: pre-wrap;
max-width: 100%;
}
.result-close {
margin-left: 4px;
margin-left: auto;
cursor: pointer;
font-size: 12px;
opacity: 0.7;
font-size: 16px;
opacity: 0.55;
flex-shrink: 0;
transition: opacity 0.2s;
}
.result-close:hover {
opacity: 1;
}
.crawl-stats-summary {
flex-wrap: wrap;
font-size: 14px;
}
.per-plugin-breakdown {
list-style: none;
margin: 12px 0 0;
padding: 0;
max-height: 360px;
overflow-y: auto;
border-top: 1px solid var(--border);
padding-top: 12px;
}
.per-plugin-line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
padding: 8px 0;
border-bottom: 1px solid var(--border);
font-size: 14px;
}
.per-plugin-line:last-child {
border-bottom: none;
}
.pp-name {
font-weight: 600;
color: var(--text-primary);
min-width: 140px;
}
.pp-detail {
color: var(--text-secondary);
}
.pp-detail.err {
color: var(--danger);
flex: 1;
min-width: 120px;
word-break: break-word;
}
</style>

View File

@@ -4,6 +4,18 @@
<el-card class="filter-card" shadow="hover">
<el-form :inline="true" :model="filterForm" class="form-row">
<el-form-item label="池范围">
<el-select
v-model="filterForm.poolFilter"
placeholder="全部"
style="width: 140px"
@change="handleSearch"
>
<el-option label="全部" value="all" />
<el-option label="待验证" value="pending" />
<el-option label="已验证可用" value="available" />
</el-select>
</el-form-item>
<el-form-item label="协议类型">
<el-select
v-model="filterForm.protocol"
@@ -84,6 +96,16 @@
<el-table-column type="selection" width="55" />
<el-table-column prop="ip" label="IP地址" width="150" />
<el-table-column prop="port" label="端口" width="100" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag v-if="row.validated === 0" type="warning" effect="light" size="small">
待验证
</el-tag>
<el-tag v-else type="success" effect="light" size="small">
已验证
</el-tag>
</template>
</el-table-column>
<el-table-column prop="protocol" label="协议" width="100">
<template #default="{ row }">
<el-tag :type="getProtocolType(row.protocol)" effect="light" size="small">
@@ -164,6 +186,7 @@ const selectedProxies = ref([])
let abortController = null
const filterForm = reactive({
poolFilter: 'all',
protocol: '',
minScore: 0,
sortBy: 'last_check',
@@ -194,6 +217,7 @@ async function fetchProxies() {
const success = await proxyStore.fetchProxies({
page: currentPage.value,
page_size: pageSize.value,
pool_filter: filterForm.poolFilter === 'all' ? null : filterForm.poolFilter,
protocol: filterForm.protocol || null,
min_score: filterForm.minScore,
sort_by: filterForm.sortBy,
@@ -237,6 +261,7 @@ async function handleDelete(proxy) {
if (!confirmed) return
const filters = {
pool_filter: filterForm.poolFilter === 'all' ? null : filterForm.poolFilter,
protocol: filterForm.protocol || null,
min_score: filterForm.minScore,
sort_by: filterForm.sortBy,
@@ -256,6 +281,7 @@ async function handleBatchDelete() {
if (!confirmed) return
const filters = {
pool_filter: filterForm.poolFilter === 'all' ? null : filterForm.poolFilter,
protocol: filterForm.protocol || null,
min_score: filterForm.minScore,
sort_by: filterForm.sortBy,

View File

@@ -86,26 +86,9 @@
ref="formRef"
>
<el-divider content-position="left">爬虫配置</el-divider>
<el-form-item label="爬取超时" prop="crawl_timeout">
<el-input-number
v-model="settings.crawl_timeout"
:min="5"
:max="120"
:step="5"
class="setting-input"
/>
<span class="setting-suffix"></span>
</el-form-item>
<el-form-item label="最大重试次数" prop="max_retries">
<el-input-number
v-model="settings.max_retries"
:min="0"
:max="10"
class="setting-input"
/>
</el-form-item>
<p class="setting-hint" style="margin: -8px 0 16px 0">
每个爬虫插件单独限时 120 互不影响此处不再配置全局爬取超时
</p>
<el-divider content-position="left">验证配置</el-divider>
@@ -124,7 +107,7 @@
<el-input-number
v-model="settings.default_concurrency"
:min="10"
:max="200"
:max="400"
:step="10"
class="setting-input"
/>
@@ -170,6 +153,15 @@
/>
</el-form-item>
<el-form-item label="爬取后立即验证" prop="auto_validate_after_crawl">
<el-switch
v-model="settings.auto_validate_after_crawl"
active-text="开启"
inactive-text="关闭"
/>
<span class="setting-hint">关闭时爬取仅入库为待验证需手动或定时全部验证消化队列推荐</span>
</el-form-item>
<el-divider content-position="left">代理评分配置</el-divider>
<el-form-item label="最低代理分数" prop="min_proxy_score">
@@ -232,13 +224,12 @@ const saving = ref(false)
const formRef = ref(null)
const settings = reactive({
crawl_timeout: 30,
validation_timeout: 10,
max_retries: 3,
default_concurrency: 50,
validation_timeout: 6,
default_concurrency: 120,
min_proxy_score: 0,
proxy_expiry_days: 7,
auto_validate: true,
auto_validate_after_crawl: false,
validate_interval_minutes: 30,
validation_targets: []
})
@@ -255,18 +246,15 @@ const defaultValidationTargets = [
// ==================== 计算属性 ====================
const schedulerInfo = computed(() => {
if (schedulerRunning.value) {
return `验证调度器正在运行,每 ${settings.validate_interval_minutes} 分钟自动验证一次所有代理`
} else {
return '验证调度器已停止,代理不会自动验证,建议定期手动验证或开启自动验证'
return `验证调度器正在运行,每 ${settings.validate_interval_minutes} 分钟执行一次:优先验证待验证代理,再按检查时间复检已入库代理`
}
return '验证调度器已停止,待验证代理不会自动检查;可在下方开启自动验证或点击「立即验证全部」'
})
// ==================== 表单验证规则 ====================
const formRules = {
crawl_timeout: [{ type: 'number', min: 5, max: 120, message: '范围 5-120 秒', trigger: 'blur' }],
validation_timeout: [{ type: 'number', min: 3, max: 60, message: '范围 3-60 秒', trigger: 'blur' }],
max_retries: [{ type: 'number', min: 0, max: 10, message: '范围 0-10', trigger: 'blur' }],
default_concurrency: [{ type: 'number', min: 10, max: 200, message: '范围 10-200', trigger: 'blur' }],
default_concurrency: [{ type: 'number', min: 10, max: 400, message: '范围 10-400', trigger: 'blur' }],
validate_interval_minutes: [{ type: 'number', min: 5, max: 1440, message: '范围 5-1440 分钟', trigger: 'blur' }],
min_proxy_score: [{ type: 'number', min: 0, max: 100, message: '范围 0-100', trigger: 'blur' }],
proxy_expiry_days: [{ type: 'number', min: 1, max: 30, message: '范围 1-30 天', trigger: 'blur' }]
@@ -306,7 +294,7 @@ async function handleStopScheduler() {
async function handleValidateNow() {
try {
await ElMessageBox.confirm(
'确定要立即验证所有代理吗?这可能需要一些时间。',
'将按顺序验证:先处理「待验证」代理,再复检已入库代理。任务在后台执行,可能需要较长时间。',
'确认验证',
{
confirmButtonText: '开始验证',