mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-09 04:30:20 +08:00
Merge branch 'pr-548'
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev": "vite --host",
|
||||
"build": "vite build",
|
||||
"build:with-typecheck": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="modelValue"
|
||||
title="号池代理均分"
|
||||
description="选择号池和代理节点后生成分配预览,再写入账号独立代理。"
|
||||
size="3xl"
|
||||
persistent
|
||||
@update:model-value="handleOpenChange"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)]">
|
||||
<div class="space-y-1.5">
|
||||
<Label>号池</Label>
|
||||
<Select
|
||||
:model-value="selectedProviderId"
|
||||
:disabled="loadingPools || executing || poolOptions.length === 0"
|
||||
@update:model-value="(value: string) => selectedProviderId = value"
|
||||
>
|
||||
<SelectTrigger class="h-9 text-xs">
|
||||
<SelectValue
|
||||
:placeholder="loadingPools
|
||||
? '加载号池中...'
|
||||
: poolOptions.length === 0
|
||||
? '暂无可用号池'
|
||||
: '选择号池'"
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="pool in poolOptions"
|
||||
:key="pool.value"
|
||||
:value="pool.value"
|
||||
>
|
||||
{{ pool.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Label>代理节点</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-[11px]"
|
||||
:disabled="executing || proxyNodeOptions.length === 0"
|
||||
@click="selectAllProxyNodes"
|
||||
>
|
||||
全部
|
||||
</Button>
|
||||
</div>
|
||||
<MultiSelect
|
||||
v-model="selectedProxyNodeIds"
|
||||
:options="proxyNodeOptions"
|
||||
placeholder="选择代理节点"
|
||||
empty-text="暂无可用代理节点"
|
||||
trigger-class="h-9 text-xs"
|
||||
dropdown-min-width="22rem"
|
||||
:disabled="executing || proxyNodeOptions.length === 0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
|
||||
<div class="space-y-1.5">
|
||||
<Label>模式</Label>
|
||||
<div class="grid grid-cols-2 gap-2 rounded-lg border border-border/60 bg-muted/30 p-1">
|
||||
<Button
|
||||
type="button"
|
||||
class="h-8 text-xs"
|
||||
:variant="mode === 'fill' ? 'default' : 'ghost'"
|
||||
:disabled="executing"
|
||||
@click="mode = 'fill'"
|
||||
>
|
||||
<Shuffle class="mr-1.5 h-3.5 w-3.5" />
|
||||
均衡补齐
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
class="h-8 text-xs"
|
||||
:variant="mode === 'rewrite' ? 'default' : 'ghost'"
|
||||
:disabled="executing"
|
||||
@click="mode = 'rewrite'"
|
||||
>
|
||||
<RefreshCw class="mr-1.5 h-3.5 w-3.5" />
|
||||
强制重排
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="h-9 px-3 text-xs"
|
||||
:disabled="loadingKeys || executing || !canBuildPlan"
|
||||
@click="loadKeysAndBuildPlan"
|
||||
>
|
||||
<RefreshCw
|
||||
class="mr-1.5 h-3.5 w-3.5"
|
||||
:class="{ 'animate-spin': loadingKeys }"
|
||||
/>
|
||||
{{ plan ? '刷新预览' : '生成预览' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loadingPools || loadingKeys"
|
||||
class="rounded-lg border border-border/60 bg-muted/20 px-3 py-6 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ loadingText }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!plan"
|
||||
class="rounded-lg border border-border/60 bg-muted/20 px-3 py-6 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
选择号池和代理节点后生成分配预览
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="space-y-3"
|
||||
>
|
||||
<div class="grid gap-2 text-xs sm:grid-cols-4">
|
||||
<div class="rounded-lg border bg-background px-3 py-2">
|
||||
<div class="text-muted-foreground">号池账号</div>
|
||||
<div class="mt-1 text-base font-semibold tabular-nums">{{ plan.totalKeys }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border bg-background px-3 py-2">
|
||||
<div class="text-muted-foreground">代理节点</div>
|
||||
<div class="mt-1 text-base font-semibold tabular-nums">{{ plan.nodeCount }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border bg-background px-3 py-2">
|
||||
<div class="text-muted-foreground">单节点上限</div>
|
||||
<div class="mt-1 text-base font-semibold tabular-nums">{{ plan.maxPerNode }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border bg-background px-3 py-2">
|
||||
<div class="text-muted-foreground">待写入</div>
|
||||
<div class="mt-1 text-base font-semibold tabular-nums">{{ plan.changedCount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
<span v-if="mode === 'fill'">
|
||||
保留 {{ plan.retainedCount }} 个有效既有绑定,处理 {{ plan.overflowCount }} 个超额绑定和 {{ plan.outsideSelectedProxyCount }} 个非选中节点绑定。
|
||||
</span>
|
||||
<span v-else>
|
||||
将全部 {{ plan.totalKeys }} 个账号随机重新分配到选中的代理节点。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[360px] overflow-y-auto rounded-lg border">
|
||||
<div
|
||||
v-for="item in assignmentRows"
|
||||
:key="item.nodeId"
|
||||
class="grid gap-2 border-b px-3 py-2 last:border-b-0 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-medium">
|
||||
{{ item.nodeName }}
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{{ item.nodeMeta }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs">
|
||||
<Badge variant="outline">目标 {{ item.targetCount }}</Badge>
|
||||
<Badge variant="secondary">保留 {{ item.retainedCount }}</Badge>
|
||||
<Badge :variant="item.changedCount > 0 ? 'default' : 'outline'">
|
||||
写入 {{ item.changedCount }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="executing && progressTotal > 0"
|
||||
class="space-y-1"
|
||||
>
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>正在写入代理绑定...</span>
|
||||
<span>{{ progressDone }} / {{ progressTotal }}</span>
|
||||
</div>
|
||||
<div class="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="h-full rounded-full bg-primary transition-all duration-150"
|
||||
:style="{ width: `${Math.round((progressDone / progressTotal) * 100)}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="executing"
|
||||
@click="handleOpenChange(false)"
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="executing || !plan || plan.changedCount === 0"
|
||||
@click="executePlan"
|
||||
>
|
||||
<Loader2
|
||||
v-if="executing"
|
||||
class="mr-1.5 h-3.5 w-3.5 animate-spin"
|
||||
/>
|
||||
{{ executing ? '执行中...' : '执行分配' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Loader2, RefreshCw, Shuffle } from 'lucide-vue-next'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Dialog,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui'
|
||||
import { MultiSelect } from '@/components/common'
|
||||
import type { MultiSelectOption } from '@/components/common/MultiSelect.vue'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||
import { formatRegion } from '@/utils/region'
|
||||
import {
|
||||
batchActionPoolKeys,
|
||||
getPoolOverview,
|
||||
listPoolKeys,
|
||||
type PoolKeyDetail,
|
||||
type PoolOverviewItem,
|
||||
} from '@/api/endpoints/pool'
|
||||
import {
|
||||
buildPoolProxyDistributionPlan,
|
||||
type PoolProxyDistributionMode,
|
||||
type PoolProxyDistributionPlan,
|
||||
} from '@/features/pool/utils/poolProxyDistribution'
|
||||
import type { ProxyNode } from '@/api/proxy-nodes'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
changed: []
|
||||
}>()
|
||||
|
||||
const { success, error: showError, warning } = useToast()
|
||||
const { confirm } = useConfirm()
|
||||
const proxyNodesStore = useProxyNodesStore()
|
||||
|
||||
const loadingPools = ref(false)
|
||||
const loadingKeys = ref(false)
|
||||
const executing = ref(false)
|
||||
const pools = ref<PoolOverviewItem[]>([])
|
||||
const selectedProviderId = ref('')
|
||||
const selectedProxyNodeIds = ref<string[]>([])
|
||||
const mode = ref<PoolProxyDistributionMode>('fill')
|
||||
const poolKeys = ref<PoolKeyDetail[]>([])
|
||||
const plan = ref<PoolProxyDistributionPlan | null>(null)
|
||||
const loadedKeysProviderId = ref('')
|
||||
const progressDone = ref(0)
|
||||
const progressTotal = ref(0)
|
||||
const loadingKeyPage = ref(0)
|
||||
|
||||
let poolLoadRequestId = 0
|
||||
let keyLoadRequestId = 0
|
||||
|
||||
const poolOptions = computed<MultiSelectOption[]>(() =>
|
||||
pools.value
|
||||
.filter(pool => pool.pool_enabled)
|
||||
.map(pool => ({
|
||||
value: pool.provider_id,
|
||||
label: `${pool.provider_name} (${pool.total_keys})`,
|
||||
})),
|
||||
)
|
||||
|
||||
const selectableProxyNodes = computed<ProxyNode[]>(() => {
|
||||
const online = proxyNodesStore.onlineNodes
|
||||
return online.length > 0 ? online : []
|
||||
})
|
||||
|
||||
const proxyNodeOptions = computed<MultiSelectOption[]>(() =>
|
||||
selectableProxyNodes.value.map(node => ({
|
||||
value: node.id,
|
||||
label: `${node.name}${node.region ? ` · ${formatRegion(node.region, '')}` : ''} (${node.ip}:${node.port})`,
|
||||
})),
|
||||
)
|
||||
|
||||
const selectedNodes = computed(() => {
|
||||
const selectedSet = new Set(selectedProxyNodeIds.value)
|
||||
return selectableProxyNodes.value.filter(node => selectedSet.has(node.id))
|
||||
})
|
||||
|
||||
const canBuildPlan = computed(() =>
|
||||
Boolean(selectedProviderId.value) && selectedNodes.value.length > 0,
|
||||
)
|
||||
|
||||
const loadingText = computed(() => {
|
||||
if (loadingKeys.value) {
|
||||
return loadingKeyPage.value > 0
|
||||
? `正在加载账号列表,第 ${loadingKeyPage.value} 页...`
|
||||
: '正在加载账号列表...'
|
||||
}
|
||||
return '正在加载号池和代理节点...'
|
||||
})
|
||||
|
||||
const assignmentRows = computed(() => {
|
||||
const nodeById = new Map(selectableProxyNodes.value.map(node => [node.id, node]))
|
||||
return (plan.value?.assignments ?? []).map((assignment) => {
|
||||
const node = nodeById.get(assignment.nodeId)
|
||||
return {
|
||||
nodeId: assignment.nodeId,
|
||||
nodeName: node?.name || assignment.nodeId,
|
||||
nodeMeta: node ? `${node.ip}:${node.port}${node.region ? ` | ${formatRegion(node.region, '')}` : ''}` : assignment.nodeId,
|
||||
targetCount: assignment.targetCount,
|
||||
retainedCount: assignment.retainedKeys.length,
|
||||
changedCount: assignment.changedKeys.length,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function handleOpenChange(open: boolean): void {
|
||||
emit('update:modelValue', open)
|
||||
}
|
||||
|
||||
function selectAllProxyNodes(): void {
|
||||
selectedProxyNodeIds.value = proxyNodeOptions.value.map(option => option.value)
|
||||
}
|
||||
|
||||
function resetPreview(): void {
|
||||
plan.value = null
|
||||
poolKeys.value = []
|
||||
loadedKeysProviderId.value = ''
|
||||
progressDone.value = 0
|
||||
progressTotal.value = 0
|
||||
loadingKeyPage.value = 0
|
||||
}
|
||||
|
||||
async function loadInitialData(): Promise<void> {
|
||||
const requestId = ++poolLoadRequestId
|
||||
loadingPools.value = true
|
||||
resetPreview()
|
||||
try {
|
||||
await proxyNodesStore.ensureLoaded()
|
||||
const overview = await getPoolOverview({ cacheTtlMs: 0 })
|
||||
if (requestId !== poolLoadRequestId) return
|
||||
pools.value = Array.isArray(overview.items) ? overview.items : []
|
||||
if (!selectedProviderId.value || !poolOptions.value.some(option => option.value === selectedProviderId.value)) {
|
||||
selectedProviderId.value = poolOptions.value[0]?.value ?? ''
|
||||
}
|
||||
selectAllProxyNodes()
|
||||
} catch (err) {
|
||||
if (requestId !== poolLoadRequestId) return
|
||||
showError(parseApiError(err, '加载号池信息失败'))
|
||||
} finally {
|
||||
if (requestId === poolLoadRequestId) {
|
||||
loadingPools.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllPoolKeys(providerId: string): Promise<PoolKeyDetail[]> {
|
||||
const pageSize = 200
|
||||
let page = 1
|
||||
const keys: PoolKeyDetail[] = []
|
||||
|
||||
while (true) {
|
||||
loadingKeyPage.value = page
|
||||
const result = await listPoolKeys(providerId, {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
status: 'all',
|
||||
}, {
|
||||
cacheTtlMs: 0,
|
||||
})
|
||||
const pageKeys = Array.isArray(result.keys) ? result.keys : []
|
||||
keys.push(...pageKeys)
|
||||
if (keys.length >= result.total || pageKeys.length === 0) {
|
||||
return keys
|
||||
}
|
||||
page += 1
|
||||
}
|
||||
}
|
||||
|
||||
async function loadKeysAndBuildPlan(): Promise<void> {
|
||||
if (!canBuildPlan.value) {
|
||||
warning('请先选择号池和代理节点')
|
||||
return
|
||||
}
|
||||
|
||||
const providerId = selectedProviderId.value
|
||||
const requestId = ++keyLoadRequestId
|
||||
loadingKeys.value = true
|
||||
plan.value = null
|
||||
try {
|
||||
const keys = await loadAllPoolKeys(providerId)
|
||||
if (requestId !== keyLoadRequestId || selectedProviderId.value !== providerId) return
|
||||
poolKeys.value = keys
|
||||
loadedKeysProviderId.value = providerId
|
||||
buildPreviewPlan()
|
||||
} catch (err) {
|
||||
if (requestId !== keyLoadRequestId) return
|
||||
showError(parseApiError(err, '加载号池账号失败'))
|
||||
} finally {
|
||||
if (requestId === keyLoadRequestId) {
|
||||
loadingKeys.value = false
|
||||
loadingKeyPage.value = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildPreviewPlan(): void {
|
||||
if (!canBuildPlan.value || loadedKeysProviderId.value !== selectedProviderId.value) {
|
||||
plan.value = null
|
||||
return
|
||||
}
|
||||
plan.value = buildPoolProxyDistributionPlan({
|
||||
mode: mode.value,
|
||||
keys: poolKeys.value,
|
||||
nodes: selectedNodes.value.map(node => ({ id: node.id, name: node.name })),
|
||||
})
|
||||
}
|
||||
|
||||
async function executePlan(): Promise<void> {
|
||||
if (!plan.value || !selectedProviderId.value) return
|
||||
if (plan.value.changedCount === 0) {
|
||||
success('当前分配已经满足目标,无需写入')
|
||||
emit('changed')
|
||||
emit('update:modelValue', false)
|
||||
return
|
||||
}
|
||||
|
||||
const confirmed = await confirm({
|
||||
title: '执行号池代理均分',
|
||||
message: `将写入 ${plan.value.changedCount} 个账号代理绑定,是否继续?`,
|
||||
confirmText: '开始分配',
|
||||
variant: 'warning',
|
||||
})
|
||||
if (!confirmed || !plan.value) return
|
||||
|
||||
executing.value = true
|
||||
progressDone.value = 0
|
||||
progressTotal.value = plan.value.changedCount
|
||||
const providerId = selectedProviderId.value
|
||||
let affected = 0
|
||||
|
||||
try {
|
||||
for (const assignment of plan.value.assignments) {
|
||||
const keyIds = assignment.changedKeys.map(key => key.key_id)
|
||||
for (let index = 0; index < keyIds.length; index += 2000) {
|
||||
const batch = keyIds.slice(index, index + 2000)
|
||||
if (batch.length === 0) continue
|
||||
const result = await batchActionPoolKeys(providerId, {
|
||||
key_ids: batch,
|
||||
action: 'set_proxy',
|
||||
payload: { node_id: assignment.nodeId, enabled: true },
|
||||
})
|
||||
affected += Number(result.affected || 0)
|
||||
progressDone.value += batch.length
|
||||
}
|
||||
}
|
||||
|
||||
success(`号池代理均分完成,已写入 ${affected} 个账号`)
|
||||
emit('changed')
|
||||
emit('update:modelValue', false)
|
||||
} catch (err) {
|
||||
showError(parseApiError(err, '执行号池代理均分失败'))
|
||||
} finally {
|
||||
executing.value = false
|
||||
progressDone.value = 0
|
||||
progressTotal.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (open) {
|
||||
void loadInitialData()
|
||||
} else {
|
||||
keyLoadRequestId += 1
|
||||
resetPreview()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch([selectedProviderId, selectedProxyNodeIds, mode], () => {
|
||||
if (!props.modelValue || loadingKeys.value || executing.value) return
|
||||
if (loadedKeysProviderId.value === selectedProviderId.value && poolKeys.value.length > 0) {
|
||||
buildPreviewPlan()
|
||||
} else {
|
||||
plan.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildPoolProxyDistributionPlan,
|
||||
type PoolProxyDistributionKey,
|
||||
} from '@/features/pool/utils/poolProxyDistribution'
|
||||
|
||||
const nodes = [
|
||||
{ id: 'node-a', name: 'Node A' },
|
||||
{ id: 'node-b', name: 'Node B' },
|
||||
]
|
||||
|
||||
function key(id: string, nodeId?: string | null): PoolProxyDistributionKey {
|
||||
return {
|
||||
key_id: id,
|
||||
key_name: id,
|
||||
proxy: nodeId ? { node_id: nodeId, enabled: true } : null,
|
||||
}
|
||||
}
|
||||
|
||||
function fixedRng(): () => number {
|
||||
return () => 0
|
||||
}
|
||||
|
||||
function assignedIds(plan: ReturnType<typeof buildPoolProxyDistributionPlan>): string[] {
|
||||
return plan.assignments.flatMap(item => item.keys.map(key => key.key_id)).sort()
|
||||
}
|
||||
|
||||
describe('buildPoolProxyDistributionPlan', () => {
|
||||
it('keeps existing selected-node proxy bindings and fills empty capacity', () => {
|
||||
const plan = buildPoolProxyDistributionPlan({
|
||||
mode: 'fill',
|
||||
nodes,
|
||||
rng: fixedRng(),
|
||||
keys: [
|
||||
key('a-1', 'node-a'),
|
||||
key('a-2', 'node-a'),
|
||||
key('b-1', 'node-b'),
|
||||
key('new-1', null),
|
||||
],
|
||||
})
|
||||
|
||||
expect(plan.totalKeys).toBe(4)
|
||||
expect(plan.maxPerNode).toBe(2)
|
||||
expect(plan.retainedCount).toBe(3)
|
||||
expect(plan.changedCount).toBe(1)
|
||||
expect(assignedIds(plan)).toEqual(['a-1', 'a-2', 'b-1', 'new-1'])
|
||||
expect(plan.assignments.map(item => item.keys).map(keys => keys.length).sort()).toEqual([2, 2])
|
||||
})
|
||||
|
||||
it('moves overflowed existing bindings before final assignment', () => {
|
||||
const plan = buildPoolProxyDistributionPlan({
|
||||
mode: 'fill',
|
||||
nodes,
|
||||
rng: fixedRng(),
|
||||
keys: [
|
||||
key('a-1', 'node-a'),
|
||||
key('a-2', 'node-a'),
|
||||
key('a-3', 'node-a'),
|
||||
key('a-4', 'node-a'),
|
||||
key('b-1', 'node-b'),
|
||||
],
|
||||
})
|
||||
|
||||
const nodeA = plan.assignments.find(item => item.nodeId === 'node-a')!
|
||||
const nodeB = plan.assignments.find(item => item.nodeId === 'node-b')!
|
||||
|
||||
expect(plan.maxPerNode).toBe(3)
|
||||
expect(plan.overflowCount).toBe(1)
|
||||
expect(nodeA.keys).toHaveLength(3)
|
||||
expect(nodeB.keys).toHaveLength(2)
|
||||
expect(nodeB.changedKeys).toHaveLength(1)
|
||||
expect(assignedIds(plan)).toEqual(['a-1', 'a-2', 'a-3', 'a-4', 'b-1'])
|
||||
})
|
||||
|
||||
it('reassigns accounts bound to non-selected proxy nodes', () => {
|
||||
const plan = buildPoolProxyDistributionPlan({
|
||||
mode: 'fill',
|
||||
nodes,
|
||||
rng: fixedRng(),
|
||||
keys: [
|
||||
key('outside-1', 'node-c'),
|
||||
key('empty-1', null),
|
||||
],
|
||||
})
|
||||
|
||||
expect(plan.outsideSelectedProxyCount).toBe(1)
|
||||
expect(plan.changedCount).toBe(2)
|
||||
expect(plan.assignments.every(item => item.keys.length === 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('force rewrites all accounts into balanced random targets', () => {
|
||||
const plan = buildPoolProxyDistributionPlan({
|
||||
mode: 'rewrite',
|
||||
nodes,
|
||||
rng: fixedRng(),
|
||||
keys: [
|
||||
key('k-1', 'node-a'),
|
||||
key('k-2', 'node-a'),
|
||||
key('k-3', 'node-b'),
|
||||
key('k-4', null),
|
||||
key('k-5', 'node-c'),
|
||||
],
|
||||
})
|
||||
|
||||
expect(plan.maxPerNode).toBe(3)
|
||||
expect(plan.retainedCount).toBe(0)
|
||||
expect(assignedIds(plan)).toEqual(['k-1', 'k-2', 'k-3', 'k-4', 'k-5'])
|
||||
expect(plan.assignments.map(item => item.keys).map(keys => keys.length).sort()).toEqual([2, 3])
|
||||
expect(plan.assignments.every(item => item.keys.length <= plan.maxPerNode)).toBe(true)
|
||||
})
|
||||
|
||||
it('supports fewer accounts than selected proxy nodes', () => {
|
||||
const plan = buildPoolProxyDistributionPlan({
|
||||
mode: 'rewrite',
|
||||
nodes: [
|
||||
...nodes,
|
||||
{ id: 'node-c', name: 'Node C' },
|
||||
],
|
||||
rng: fixedRng(),
|
||||
keys: [key('only-1', null)],
|
||||
})
|
||||
|
||||
expect(plan.maxPerNode).toBe(1)
|
||||
expect(plan.assignments.map(item => item.keys).map(keys => keys.length).sort()).toEqual([0, 0, 1])
|
||||
expect(assignedIds(plan)).toEqual(['only-1'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,266 @@
|
||||
export type PoolProxyDistributionMode = 'fill' | 'rewrite'
|
||||
|
||||
export interface PoolProxyDistributionKey {
|
||||
key_id: string
|
||||
key_name?: string | null
|
||||
proxy?: {
|
||||
node_id?: string | null
|
||||
enabled?: boolean
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface PoolProxyDistributionNode {
|
||||
id: string
|
||||
name?: string | null
|
||||
}
|
||||
|
||||
export interface PoolProxyDistributionAssignment {
|
||||
nodeId: string
|
||||
targetCount: number
|
||||
retainedKeys: PoolProxyDistributionKey[]
|
||||
assignedKeys: PoolProxyDistributionKey[]
|
||||
changedKeys: PoolProxyDistributionKey[]
|
||||
keys: PoolProxyDistributionKey[]
|
||||
}
|
||||
|
||||
export interface PoolProxyDistributionPlan {
|
||||
mode: PoolProxyDistributionMode
|
||||
totalKeys: number
|
||||
nodeCount: number
|
||||
maxPerNode: number
|
||||
assignments: PoolProxyDistributionAssignment[]
|
||||
retainedCount: number
|
||||
changedCount: number
|
||||
outsideSelectedProxyCount: number
|
||||
overflowCount: number
|
||||
}
|
||||
|
||||
export interface PoolProxyDistributionOptions {
|
||||
mode: PoolProxyDistributionMode
|
||||
keys: PoolProxyDistributionKey[]
|
||||
nodes: PoolProxyDistributionNode[]
|
||||
rng?: () => number
|
||||
}
|
||||
|
||||
interface MutableAssignment {
|
||||
nodeId: string
|
||||
targetCount: number
|
||||
retainedKeys: PoolProxyDistributionKey[]
|
||||
assignedKeys: PoolProxyDistributionKey[]
|
||||
}
|
||||
|
||||
export function buildPoolProxyDistributionPlan(
|
||||
options: PoolProxyDistributionOptions,
|
||||
): PoolProxyDistributionPlan {
|
||||
const rng = options.rng ?? Math.random
|
||||
const keys = uniqueKeys(options.keys)
|
||||
const nodeIds = uniqueNodeIds(options.nodes)
|
||||
const selectedNodeSet = new Set(nodeIds)
|
||||
const totalKeys = keys.length
|
||||
const nodeCount = nodeIds.length
|
||||
const maxPerNode = nodeCount > 0 ? Math.ceil(totalKeys / nodeCount) : 0
|
||||
|
||||
if (totalKeys === 0 || nodeCount === 0) {
|
||||
return {
|
||||
mode: options.mode,
|
||||
totalKeys,
|
||||
nodeCount,
|
||||
maxPerNode,
|
||||
assignments: [],
|
||||
retainedCount: 0,
|
||||
changedCount: 0,
|
||||
outsideSelectedProxyCount: 0,
|
||||
overflowCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const targetCounts = buildTargetCounts({
|
||||
keys,
|
||||
nodeIds,
|
||||
selectedNodeSet,
|
||||
mode: options.mode,
|
||||
rng,
|
||||
})
|
||||
|
||||
const mutableAssignments = new Map<string, MutableAssignment>()
|
||||
for (const nodeId of nodeIds) {
|
||||
mutableAssignments.set(nodeId, {
|
||||
nodeId,
|
||||
targetCount: targetCounts.get(nodeId) ?? 0,
|
||||
retainedKeys: [],
|
||||
assignedKeys: [],
|
||||
})
|
||||
}
|
||||
|
||||
const pendingKeys: PoolProxyDistributionKey[] = []
|
||||
let outsideSelectedProxyCount = 0
|
||||
let overflowCount = 0
|
||||
|
||||
if (options.mode === 'fill') {
|
||||
const keysByNode = new Map<string, PoolProxyDistributionKey[]>()
|
||||
for (const key of keys) {
|
||||
const nodeId = getKeyProxyNodeId(key)
|
||||
if (nodeId && selectedNodeSet.has(nodeId)) {
|
||||
const grouped = keysByNode.get(nodeId) ?? []
|
||||
grouped.push(key)
|
||||
keysByNode.set(nodeId, grouped)
|
||||
} else {
|
||||
if (nodeId) outsideSelectedProxyCount += 1
|
||||
pendingKeys.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
for (const nodeId of nodeIds) {
|
||||
const assignment = mutableAssignments.get(nodeId)
|
||||
if (!assignment) continue
|
||||
const currentKeys = shuffle(keysByNode.get(nodeId) ?? [], rng)
|
||||
const retainedKeys = currentKeys.slice(0, assignment.targetCount)
|
||||
const overflowKeys = currentKeys.slice(assignment.targetCount)
|
||||
assignment.retainedKeys.push(...retainedKeys)
|
||||
pendingKeys.push(...overflowKeys)
|
||||
overflowCount += overflowKeys.length
|
||||
}
|
||||
} else {
|
||||
pendingKeys.push(...keys)
|
||||
}
|
||||
|
||||
const shuffledPendingKeys = shuffle(pendingKeys, rng)
|
||||
const slots = shuffle(buildOpenSlots(mutableAssignments), rng)
|
||||
for (let index = 0; index < shuffledPendingKeys.length; index += 1) {
|
||||
const nodeId = slots[index]
|
||||
if (!nodeId) break
|
||||
mutableAssignments.get(nodeId)?.assignedKeys.push(shuffledPendingKeys[index])
|
||||
}
|
||||
|
||||
const assignments = nodeIds.map((nodeId) => {
|
||||
const assignment = mutableAssignments.get(nodeId)!
|
||||
const nodeKeys = [...assignment.retainedKeys, ...assignment.assignedKeys]
|
||||
const changedKeys = nodeKeys.filter(key => getKeyProxyNodeId(key) !== nodeId)
|
||||
return {
|
||||
nodeId,
|
||||
targetCount: assignment.targetCount,
|
||||
retainedKeys: assignment.retainedKeys,
|
||||
assignedKeys: assignment.assignedKeys,
|
||||
changedKeys,
|
||||
keys: nodeKeys,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
mode: options.mode,
|
||||
totalKeys,
|
||||
nodeCount,
|
||||
maxPerNode,
|
||||
assignments,
|
||||
retainedCount: assignments.reduce((sum, item) => sum + item.retainedKeys.length, 0),
|
||||
changedCount: assignments.reduce((sum, item) => sum + item.changedKeys.length, 0),
|
||||
outsideSelectedProxyCount,
|
||||
overflowCount,
|
||||
}
|
||||
}
|
||||
|
||||
function buildTargetCounts(options: {
|
||||
keys: PoolProxyDistributionKey[]
|
||||
nodeIds: string[]
|
||||
selectedNodeSet: Set<string>
|
||||
mode: PoolProxyDistributionMode
|
||||
rng: () => number
|
||||
}): Map<string, number> {
|
||||
const baseCount = Math.floor(options.keys.length / options.nodeIds.length)
|
||||
const extraCount = options.keys.length % options.nodeIds.length
|
||||
const existingCounts = new Map<string, number>()
|
||||
|
||||
if (options.mode === 'fill') {
|
||||
for (const key of options.keys) {
|
||||
const nodeId = getKeyProxyNodeId(key)
|
||||
if (nodeId && options.selectedNodeSet.has(nodeId)) {
|
||||
existingCounts.set(nodeId, (existingCounts.get(nodeId) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const extraNodeIds = new Set(
|
||||
options.nodeIds
|
||||
.map(nodeId => ({
|
||||
nodeId,
|
||||
existingCount: existingCounts.get(nodeId) ?? 0,
|
||||
rank: options.rng(),
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
if (options.mode === 'fill' && left.existingCount !== right.existingCount) {
|
||||
return right.existingCount - left.existingCount
|
||||
}
|
||||
return left.rank - right.rank
|
||||
})
|
||||
.slice(0, extraCount)
|
||||
.map(item => item.nodeId),
|
||||
)
|
||||
|
||||
return new Map(
|
||||
options.nodeIds.map((nodeId) => [
|
||||
nodeId,
|
||||
baseCount + (extraNodeIds.has(nodeId) ? 1 : 0),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
function buildOpenSlots(assignments: Map<string, MutableAssignment>): string[] {
|
||||
const slots: string[] = []
|
||||
for (const assignment of assignments.values()) {
|
||||
const openSlotCount = Math.max(
|
||||
assignment.targetCount - assignment.retainedKeys.length - assignment.assignedKeys.length,
|
||||
0,
|
||||
)
|
||||
for (let index = 0; index < openSlotCount; index += 1) {
|
||||
slots.push(assignment.nodeId)
|
||||
}
|
||||
}
|
||||
return slots
|
||||
}
|
||||
|
||||
function getKeyProxyNodeId(key: PoolProxyDistributionKey): string | null {
|
||||
const nodeId = key.proxy?.node_id?.trim()
|
||||
return nodeId || null
|
||||
}
|
||||
|
||||
function uniqueNodeIds(nodes: PoolProxyDistributionNode[]): string[] {
|
||||
const seen = new Set<string>()
|
||||
const ids: string[] = []
|
||||
for (const node of nodes) {
|
||||
const id = node.id.trim()
|
||||
if (!id || seen.has(id)) continue
|
||||
seen.add(id)
|
||||
ids.push(id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
function uniqueKeys(keys: PoolProxyDistributionKey[]): PoolProxyDistributionKey[] {
|
||||
const seen = new Set<string>()
|
||||
const items: PoolProxyDistributionKey[] = []
|
||||
for (const key of keys) {
|
||||
const id = key.key_id.trim()
|
||||
if (!id || seen.has(id)) continue
|
||||
seen.add(id)
|
||||
items.push(key)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
function shuffle<T>(items: T[], rng: () => number): T[] {
|
||||
const result = [...items]
|
||||
for (let index = result.length - 1; index > 0; index -= 1) {
|
||||
const swapIndex = Math.floor(clampRandom(rng()) * (index + 1))
|
||||
const current = result[index]
|
||||
result[index] = result[swapIndex]
|
||||
result[swapIndex] = current
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function clampRandom(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0
|
||||
if (value < 0) return 0
|
||||
if (value >= 1) return 0.999999999
|
||||
return value
|
||||
}
|
||||
@@ -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:password@1.2.3.4:1080 http://username:password@5.6.7.8: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 ?? {}
|
||||
|
||||
@@ -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:secret@1.2.3.4:1080',
|
||||
'http://bob:pwd@5.6.7.8:8080, https://carol:p%40ss@example.com: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:pass@127.0.0.1:1080, ftp://user:pass@127.0.0.1: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,
|
||||
|
||||
Reference in New Issue
Block a user