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:
@@ -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 }),
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
134
WebUI/src/composables/useStatsWebSocket.js
Normal file
134
WebUI/src/composables/useStatsWebSocket.js
Normal 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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '开始验证',
|
||||
|
||||
Reference in New Issue
Block a user