feat: 代理池均衡分发&批量添加代理节点

This commit is contained in:
wzw
2026-05-23 10:40:25 +08:00
parent c7641dad0a
commit bf456450d7
10 changed files with 1335 additions and 17 deletions
+162 -4
View File
@@ -13,6 +13,14 @@
代理节点
</h3>
<div class="flex items-center gap-2">
<Button
size="sm"
variant="outline"
class="h-7 text-xs"
@click="showPoolProxyDistributionDialog = true"
>
均分
</Button>
<Button
size="sm"
variant="outline"
@@ -101,6 +109,15 @@
</Select>
</div>
<div class="h-4 w-px bg-border" />
<Button
variant="outline"
size="sm"
class="h-8 text-xs"
@click="showPoolProxyDistributionDialog = true"
>
<Shuffle class="w-3.5 h-3.5 mr-1.5" />
号池均分
</Button>
<Button
variant="outline"
size="sm"
@@ -524,14 +541,14 @@
<Dialog
:model-value="showAddDialog"
:title="editingNode ? '编辑代理节点' : '添加代理节点'"
:description="editingNode ? '修改手动代理节点的配置' : '推荐使用一键脚本部署 aether-tunnel,也可手动添加已有 HTTP/SOCKS 代理'"
:description="editingNode ? '修改手动代理节点的配置' : '推荐使用一键脚本部署 aether-tunnel,也可手动或批量添加已有 HTTP/SOCKS 代理'"
:icon="editingNode ? SquarePen : Plus"
size="lg"
@update:model-value="handleDialogClose"
>
<div
v-if="!editingNode"
class="mb-4 grid grid-cols-2 gap-2 rounded-lg border border-border/60 bg-muted/30 p-1"
class="mb-4 grid grid-cols-1 sm:grid-cols-3 gap-2 rounded-lg border border-border/60 bg-muted/30 p-1"
>
<Button
type="button"
@@ -551,6 +568,15 @@
<Plus class="w-3.5 h-3.5 mr-1.5" />
手动添加
</Button>
<Button
type="button"
:variant="addMode === 'batch' ? 'default' : 'ghost'"
class="h-9"
@click="addMode = 'batch'"
>
<ListPlus class="w-3.5 h-3.5 mr-1.5" />
批量添加
</Button>
</div>
<div
@@ -632,6 +658,53 @@
</div>
</div>
<div
v-else-if="!editingNode && addMode === 'batch'"
class="space-y-4"
>
<div class="rounded-lg border border-border/60 bg-muted/30 p-3 text-xs text-muted-foreground">
支持一行一个,或使用英文逗号分隔。URL 中的用户名和密码会自动拆分到手动添加接口,节点名称自动使用主机和端口。
</div>
<div class="space-y-1.5">
<Label>代理地址 *</Label>
<Textarea
v-model="batchForm.content"
class="min-h-[180px] font-mono text-xs break-all !rounded-xl"
placeholder="socks5://username:[email protected]:1080&#10;http://username:[email protected]:8080"
/>
</div>
<div
v-if="batchParseResult.errors.length"
class="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive"
>
<div class="font-medium">
有 {{ batchParseResult.errors.length }} 条格式错误
</div>
<ul class="mt-1 space-y-1">
<li
v-for="message in batchParseResult.errors.slice(0, 3)"
:key="message"
>
{{ message }}
</li>
</ul>
<div
v-if="batchParseResult.errors.length > 3"
class="mt-1 text-destructive/80"
>
还有 {{ batchParseResult.errors.length - 3 }} 条错误未显示
</div>
</div>
<p
v-else-if="batchForm.content.trim()"
class="text-xs text-muted-foreground"
>
已识别 {{ batchParseResult.nodes.length }} 个代理节点。
</p>
</div>
<form
v-else
class="space-y-4"
@@ -704,6 +777,28 @@
{{ installCopied ? '已复制' : '复制命令' }}
</Button>
</div>
<div
v-else-if="!editingNode && addMode === 'batch'"
class="flex items-center justify-between gap-3 w-full"
>
<span class="text-xs text-muted-foreground">
{{ batchForm.content.trim() ? `待添加 ${batchParseResult.nodes.length} 个` : '等待输入代理地址' }}
</span>
<div class="flex items-center gap-2">
<Button
variant="outline"
@click="handleDialogClose(false)"
>
取消
</Button>
<Button
:disabled="addingNode || !batchForm.content.trim() || batchParseResult.errors.length > 0 || batchParseResult.nodes.length === 0"
@click="handleBatchAddManualNodes"
>
{{ addingNode ? '添加中...' : '批量添加' }}
</Button>
</div>
</div>
<div
v-else
class="flex items-center justify-between w-full"
@@ -883,6 +978,10 @@
</template>
</Dialog>
<PoolProxyDistributionDialog
v-model="showPoolProxyDistributionDialog"
/>
<!-- 连接事件对话框 -->
<Dialog
:open="showEventsDialog"
@@ -995,14 +1094,17 @@ import {
Pagination,
RefreshButton,
Dialog,
Textarea,
} from '@/components/ui'
import { Search, Trash2, Plus, SquarePen, Activity, Loader2, Settings, History, ChevronDown, ChevronRight, Terminal, Copy, CheckCircle } from 'lucide-vue-next'
import { Search, Trash2, Plus, SquarePen, Activity, Loader2, Settings, History, ChevronDown, ChevronRight, Terminal, Copy, CheckCircle, ListPlus, Shuffle } from 'lucide-vue-next'
import { parseApiError } from '@/utils/errorParser'
import { formatCompactNumber } from '@/utils/format'
import { formatRegion } from '@/utils/region'
import { parseBatchProxyNodeInput } from './proxy-node-batch'
import HardwareTooltip from './components/HardwareTooltip.vue'
import ProxyNodeDataPanel from './components/ProxyNodeDataPanel.vue'
import PoolProxyDistributionDialog from '@/features/pool/components/PoolProxyDistributionDialog.vue'
const { success, error: toastError } = useToast()
const { confirmDanger } = useConfirm()
@@ -1021,9 +1123,10 @@ const pageSize = ref(20)
// 手动添加/编辑对话框
const showAddDialog = ref(false)
const showPoolProxyDistributionDialog = ref(false)
const addingNode = ref(false)
const editingNode = ref<ProxyNode | null>(null)
const addMode = ref<'script' | 'manual'>('script')
const addMode = ref<'script' | 'manual' | 'batch'>('script')
const addForm = ref({
name: '',
proxy_url: '',
@@ -1031,6 +1134,9 @@ const addForm = ref({
password: '',
region: '',
})
const batchForm = ref({
content: '',
})
const installForm = ref({
node_name: '',
})
@@ -1054,6 +1160,8 @@ const proxyInstallHint = computed(() => {
return `这条命令将在 ${Math.floor(proxyInstallSession.value.expires_in_seconds / 60)} 分钟内有效,成功使用后立即失效。`
})
const batchParseResult = computed(() => parseBatchProxyNodeInput(batchForm.value.content))
// 远程配置对话框 (aether-tunnel 节点)
const showConfigDialog = ref(false)
const savingConfig = ref(false)
@@ -1196,6 +1304,7 @@ function openAddDialog() {
editingNode.value = null
addMode.value = 'script'
addForm.value = { name: '', proxy_url: '', username: '', password: '', region: '' }
batchForm.value = { content: '' }
installForm.value = { node_name: '' }
resetProxyInstallState()
showAddDialog.value = true
@@ -1254,6 +1363,7 @@ function handleDialogClose(open: boolean) {
editingNode.value = null
addMode.value = 'script'
addForm.value = { name: '', proxy_url: '', username: '', password: '', region: '' }
batchForm.value = { content: '' }
installForm.value = { node_name: '' }
resetProxyInstallState()
}
@@ -1303,6 +1413,54 @@ async function handleAddManualNode() {
}
}
async function handleBatchAddManualNodes() {
const { nodes, errors } = batchParseResult.value
if (!batchForm.value.content.trim() || addingNode.value) return
if (errors.length > 0) {
toastError(`批量输入存在 ${errors.length} 条格式错误,请先修正后再添加`)
return
}
if (nodes.length === 0) {
toastError('请先输入至少一条代理地址')
return
}
addingNode.value = true
const failures: string[] = []
let successCount = 0
try {
for (const node of nodes) {
try {
await proxyNodesApi.createManualNode(node)
successCount += 1
} catch (err: unknown) {
failures.push(`${node.name}: ${parseApiError(err, '添加失败')}`)
}
}
await store.fetchNodes()
if (successCount > 0 && failures.length === 0) {
success(`已添加 ${successCount} 个代理节点`)
handleDialogClose(false)
return
}
if (successCount > 0) {
success(`已添加 ${successCount} 个代理节点,${failures.length} 个失败`)
}
if (failures.length > 0) {
toastError(failures.slice(0, 3).join(';'))
}
} catch (err: unknown) {
toastError(parseApiError(err, '批量添加失败'))
} finally {
addingNode.value = false
}
}
function handleConfig(node: ProxyNode) {
configNode.value = node
const rc: ProxyNodeRemoteConfig = node.remote_config ?? {}
+19 -6
View File
@@ -8,13 +8,25 @@
description="管理系统级别的配置和参数"
/>
<div class="mt-6 space-y-6">
<div
class="mt-6 space-y-6 transition-opacity"
:class="{ 'pointer-events-none opacity-60': systemConfigLoading }"
:inert="systemConfigLoading"
:aria-busy="systemConfigLoading"
>
<div
v-if="systemConfigLoading"
class="rounded-lg border border-border bg-card px-4 py-3 text-sm text-muted-foreground"
>
系统配置加载中...
</div>
<!-- 站点信息 -->
<SiteInfoSection
id="section-site-info"
:site-name="systemConfig.site_name"
:site-subtitle="systemConfig.site_subtitle"
:loading="siteInfoLoading"
:loading="systemConfigLoading || siteInfoLoading"
:has-changes="hasSiteInfoChanges"
@save="saveSiteInfo"
@update:site-name="systemConfig.site_name = $event"
@@ -40,7 +52,7 @@
:proxy-node-id="systemConfig.system_proxy_node_id"
:online-nodes="proxyNodesStore.onlineNodes"
:all-nodes="proxyNodesStore.nodes"
:loading="proxyConfigLoading"
:loading="systemConfigLoading || proxyConfigLoading"
:has-changes="hasProxyConfigChanges"
@save="saveProxyConfig"
@update:proxy-node-id="systemConfig.system_proxy_node_id = $event"
@@ -70,7 +82,7 @@
:auto-delete-expired-keys="systemConfig.auto_delete_expired_keys"
:enable-format-conversion="systemConfig.enable_format_conversion"
:enable-openai-image-sync-heartbeat="systemConfig.enable_openai_image_sync_heartbeat"
:loading="basicConfigLoading"
:loading="systemConfigLoading || basicConfigLoading"
:has-changes="hasBasicConfigChanges"
@save="saveBasicConfig"
@update:default-user-initial-gift-usd="systemConfig.default_user_initial_gift_usd = $event"
@@ -103,7 +115,7 @@
:max-request-body-size-k-b="maxRequestBodySizeKB"
:max-response-body-size-k-b="maxResponseBodySizeKB"
:sensitive-headers-str="sensitiveHeadersStr"
:loading="logConfigLoading"
:loading="systemConfigLoading || logConfigLoading"
:has-changes="hasLogConfigChanges"
@save="saveLogConfig"
@update:request-record-level="systemConfig.request_record_level = $event"
@@ -127,7 +139,7 @@
:proxy-node-metrics-1m-retention-days="systemConfig.proxy_node_metrics_1m_retention_days"
:proxy-node-metrics-1h-retention-days="systemConfig.proxy_node_metrics_1h_retention_days"
:proxy-node-metrics-cleanup-batch-size="systemConfig.proxy_node_metrics_cleanup_batch_size"
:loading="cleanupConfigLoading"
:loading="systemConfigLoading || cleanupConfigLoading"
:has-changes="hasCleanupConfigChanges"
@save="saveCleanupConfig"
@toggle-auto-cleanup="handleAutoCleanupToggle"
@@ -332,6 +344,7 @@ function setupScrollSpy() {
const {
systemConfig,
systemVersion,
systemConfigLoading,
siteInfoLoading,
proxyConfigLoading,
basicConfigLoading,
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { parseBatchProxyNodeInput } from '../proxy-node-batch'
describe('parseBatchProxyNodeInput', () => {
it('parses newline and comma separated proxy URLs into manual node payloads', () => {
const result = parseBatchProxyNodeInput([
'socks5://alice:[email protected]:1080',
'http://bob:[email protected]:8080, https://carol:p%[email protected]:8443',
].join('\n'))
expect(result.errors).toEqual([])
expect(result.nodes).toEqual([
{
name: '1.2.3.4:1080',
proxy_url: 'socks5://1.2.3.4:1080',
username: 'alice',
password: 'secret',
},
{
name: '5.6.7.8:8080',
proxy_url: 'http://5.6.7.8:8080',
username: 'bob',
password: 'pwd',
},
{
name: 'example.com:8443',
proxy_url: 'https://example.com:8443',
username: 'carol',
password: 'p@ss',
},
])
})
it('reports unsupported and invalid entries without dropping valid entries', () => {
const result = parseBatchProxyNodeInput('socks5://user:[email protected]:1080, ftp://user:[email protected]:21, nope')
expect(result.nodes).toEqual([
{
name: '127.0.0.1:1080',
proxy_url: 'socks5://127.0.0.1:1080',
username: 'user',
password: 'pass',
},
])
expect(result.errors).toHaveLength(2)
expect(result.errors[0]).toContain('仅支持 http/https/socks5/socks5h 协议')
expect(result.errors[1]).toContain('必须是合法的代理 URL')
})
})
@@ -0,0 +1,91 @@
export interface BatchManualProxyNode {
name: string
proxy_url: string
username?: string
password?: string
}
export interface BatchProxyNodeParseResult {
nodes: BatchManualProxyNode[]
errors: string[]
}
const SUPPORTED_PROXY_PROTOCOLS = new Set(['http:', 'https:', 'socks5:', 'socks5h:'])
export function parseBatchProxyNodeInput(input: string): BatchProxyNodeParseResult {
const entries = input
.split(/[\n,]+/)
.map(entry => entry.trim())
.filter(Boolean)
const nodes: BatchManualProxyNode[] = []
const errors: string[] = []
entries.forEach((entry, index) => {
try {
nodes.push(parseBatchProxyNodeEntry(entry))
} catch (err) {
const message = err instanceof Error ? err.message : '格式不正确'
errors.push(`第 ${index + 1} 条 ${entry}: ${message}`)
}
})
return { nodes, errors }
}
function parseBatchProxyNodeEntry(entry: string): BatchManualProxyNode {
let url: URL
try {
url = new URL(entry)
} catch {
throw new Error('必须是合法的代理 URL')
}
if (!SUPPORTED_PROXY_PROTOCOLS.has(url.protocol)) {
throw new Error('仅支持 http/https/socks5/socks5h 协议')
}
const host = url.hostname.trim()
if (!host) {
throw new Error('缺少主机地址')
}
const username = decodeUrlCredential(url.username, '用户名')
const password = decodeUrlCredential(url.password, '密码')
if (!username && password) {
throw new Error('包含密码时必须同时包含用户名')
}
const port = url.port || defaultProxyPort(url.protocol)
const proxyHost = url.host || `${host}:${port}`
const proxyUrl = `${url.protocol}//${proxyHost}`
return {
name: `${formatNodeNameHost(host)}:${port}`,
proxy_url: proxyUrl,
username: username || undefined,
password: password || undefined,
}
}
function defaultProxyPort(protocol: string): string {
if (protocol === 'https:') return '443'
if (protocol === 'socks5:' || protocol === 'socks5h:') return '1080'
return '80'
}
function decodeUrlCredential(value: string, field: string): string {
if (!value) return ''
try {
return decodeURIComponent(value)
} catch {
throw new Error(`${field}包含无效的 URL 编码`)
}
}
function formatNodeNameHost(host: string): string {
if (host.includes(':') && !host.startsWith('[')) {
return `[${host}]`
}
return host
}
@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { getSystemConfigMock } = vi.hoisted(() => ({
getSystemConfigMock: vi.fn(),
}))
vi.mock('@/api/admin', () => ({
adminApi: {
getSystemConfig: getSystemConfigMock,
updateSystemConfig: vi.fn(),
getSystemVersion: vi.fn(),
},
}))
vi.mock('@/composables/useToast', () => ({
useToast: () => ({
success: vi.fn(),
error: vi.fn(),
}),
}))
vi.mock('@/composables/useSiteInfo', () => ({
useSiteInfo: () => ({
refreshSiteInfo: vi.fn(),
}),
}))
vi.mock('@/utils/logger', () => ({
log: {
error: vi.fn(),
},
}))
import { useSystemConfig } from '../composables/useSystemConfig'
interface DeferredConfigResponse {
resolve: (value: { key: string, value: unknown, is_set?: boolean }) => void
}
describe('useSystemConfig', () => {
beforeEach(() => {
getSystemConfigMock.mockReset()
})
it('loads config keys in parallel and keeps change detection disabled until the baseline is ready', async () => {
const pending = new Map<string, DeferredConfigResponse>()
getSystemConfigMock.mockImplementation((key: string) => new Promise((resolve) => {
pending.set(key, { resolve })
}))
const state = useSystemConfig()
const loadPromise = state.loadSystemConfig()
expect(getSystemConfigMock.mock.calls.map(([key]) => key)).toContain('request_record_level')
expect(getSystemConfigMock.mock.calls.map(([key]) => key)).toContain('proxy_node_metrics_cleanup_batch_size')
state.systemConfig.value.request_record_level = 'headers'
expect(state.systemConfigLoading.value).toBe(true)
expect(state.hasLogConfigChanges.value).toBe(false)
for (const [key, deferred] of pending) {
deferred.resolve({
key,
value: key === 'request_record_level' ? 'basic' : undefined,
is_set: false,
})
}
await loadPromise
expect(state.systemConfigLoading.value).toBe(false)
expect(state.systemConfig.value.request_record_level).toBe('basic')
expect(state.hasLogConfigChanges.value).toBe(false)
state.systemConfig.value.request_record_level = 'full'
expect(state.hasLogConfigChanges.value).toBe(true)
})
})
@@ -177,6 +177,7 @@ export function useSystemConfig() {
const systemConfig = ref<SystemConfig>(createDefaultConfig())
const originalConfig = ref<SystemConfig | null>(null)
const systemVersion = ref<string>('')
const systemConfigLoading = ref(true)
// 各模块 loading 状态
const siteInfoLoading = ref(false)
@@ -187,6 +188,7 @@ export function useSystemConfig() {
// 变动检测
const hasSiteInfoChanges = computed(() => {
if (systemConfigLoading.value) return false
if (!originalConfig.value) return false
return (
systemConfig.value.site_name !== originalConfig.value.site_name ||
@@ -195,11 +197,13 @@ export function useSystemConfig() {
})
const hasProxyConfigChanges = computed(() => {
if (systemConfigLoading.value) return false
if (!originalConfig.value) return false
return systemConfig.value.system_proxy_node_id !== originalConfig.value.system_proxy_node_id
})
const hasBasicConfigChanges = computed(() => {
if (systemConfigLoading.value) return false
if (!originalConfig.value) return false
return (
systemConfig.value.default_user_initial_gift_usd !== originalConfig.value.default_user_initial_gift_usd ||
@@ -231,6 +235,7 @@ export function useSystemConfig() {
})
const hasLogConfigChanges = computed(() => {
if (systemConfigLoading.value) return false
if (!originalConfig.value) return false
return (
systemConfig.value.request_record_level !== originalConfig.value.request_record_level ||
@@ -242,6 +247,7 @@ export function useSystemConfig() {
})
const hasCleanupConfigChanges = computed(() => {
if (systemConfigLoading.value) return false
if (!originalConfig.value) return false
return (
systemConfig.value.detail_log_retention_days !==
@@ -304,26 +310,47 @@ export function useSystemConfig() {
// 加载配置
async function loadSystemConfig() {
systemConfigLoading.value = true
try {
for (const key of CONFIG_KEYS) {
const results = await Promise.all(
CONFIG_KEYS.map(async (key) => {
try {
return {
key,
response: await adminApi.getSystemConfig(key),
}
} catch {
return null
}
})
)
const nextConfig = createDefaultConfig()
for (const result of results) {
if (!result) {
continue
}
const { key, response } = result
try {
const response = await adminApi.getSystemConfig(key)
if (key === 'turnstile_secret_key') {
systemConfig.value.turnstile_secret_key = ''
systemConfig.value.turnstile_secret_key_is_set = !!response.is_set
nextConfig.turnstile_secret_key = ''
nextConfig.turnstile_secret_key_is_set = !!response.is_set
continue
}
if (response.value !== null && response.value !== undefined) {
; (systemConfig.value as Record<string, unknown>)[key] = response.value
; (nextConfig as Record<string, unknown>)[key] = response.value
}
} catch {
// 单个配置项加载失败时忽略,使用默认值
}
}
originalConfig.value = JSON.parse(JSON.stringify(systemConfig.value))
systemConfig.value = nextConfig
originalConfig.value = JSON.parse(JSON.stringify(nextConfig))
} catch (err) {
error('加载系统配置失败')
log.error('加载系统配置失败:', err)
} finally {
systemConfigLoading.value = false
}
}
@@ -734,6 +761,7 @@ export function useSystemConfig() {
systemConfig,
originalConfig,
systemVersion,
systemConfigLoading,
// loading 状态
siteInfoLoading,
proxyConfigLoading,