diff --git a/.gitignore b/.gitignore index 97a145a..08a3889 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,9 @@ env/ *.sqlite *.sqlite3 *.db +# pytest 隔离库(PROXYPOOL_DB_PATH=db/proxies.test.sqlite),勿提交 +**/proxies.test.sqlite +proxies.test.sqlite *.db-shm *.db-wal diff --git a/README.md b/README.md index d10eb14..08f4e33 100644 --- a/README.md +++ b/README.md @@ -221,10 +221,15 @@ POST /api/settings - **验证超时**: 3-30秒,默认 5秒 - **验证并发数**: 10-200,默认 50 -### 评分机制 +### 待验证与可用 +- **爬取**:代理默认以「待验证」入库(`validated=0`,分数为 0),不会立刻参与随机/导出。 +- **验证**:在设置页「立即验证全部」或开启自动验证后,会**先验证待验证队列**,再按检查时间**复检已入库代理**;通过后标记为已验证并赋予分数。 +- **设置**:「爬取后立即验证」默认关闭;开启后爬取完成会像旧版一样立刻排队验证。 + +### 评分机制(仅针对已验证入池的代理) - **验证成功**: +10 分 - **验证失败**: -5 分 -- **分数为 0**: 自动删除 +- **分数为 0**: 自动删除(待验证阶段验证失败则直接丢弃该条) ## 🔧 常见问题 diff --git a/WebUI/src/api/index.js b/WebUI/src/api/index.js index 3568518..eda2f0f 100644 --- a/WebUI/src/api/index.js +++ b/WebUI/src/api/index.js @@ -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 }), diff --git a/WebUI/src/components/StatCard.vue b/WebUI/src/components/StatCard.vue index a671da0..811a226 100644 --- a/WebUI/src/components/StatCard.vue +++ b/WebUI/src/components/StatCard.vue @@ -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)); diff --git a/WebUI/src/composables/useStatsWebSocket.js b/WebUI/src/composables/useStatsWebSocket.js new file mode 100644 index 0000000..7f9c7ba --- /dev/null +++ b/WebUI/src/composables/useStatsWebSocket.js @@ -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 } +} diff --git a/WebUI/src/composables/useTaskPolling.js b/WebUI/src/composables/useTaskPolling.js index 2e011e6..b001f53 100644 --- a/WebUI/src/composables/useTaskPolling.js +++ b/WebUI/src/composables/useTaskPolling.js @@ -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) } } diff --git a/WebUI/src/stores/proxy.js b/WebUI/src/stores/proxy.js index 431867f..e8ffd0a 100644 --- a/WebUI/src/stores/proxy.js +++ b/WebUI/src/stores/proxy.js @@ -32,6 +32,12 @@ export const useProxyStore = defineStore('proxy', () => { * 获取统计信息 * @returns {Promise} */ + 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, diff --git a/WebUI/src/views/Dashboard.vue b/WebUI/src/views/Dashboard.vue index fd8db06..5eb75a0 100644 --- a/WebUI/src/views/Dashboard.vue +++ b/WebUI/src/views/Dashboard.vue @@ -2,40 +2,38 @@
- - - - - - - - - - - - - - +
+ + + + + +
@@ -88,7 +86,7 @@ diff --git a/WebUI/src/views/ProxyList.vue b/WebUI/src/views/ProxyList.vue index 4d3ffbb..6325c93 100644 --- a/WebUI/src/views/ProxyList.vue +++ b/WebUI/src/views/ProxyList.vue @@ -4,6 +4,18 @@ + + + + + + + + + +