feat: enhance proxy management

This commit is contained in:
LLLLLLiulei
2026-01-14 19:45:29 +08:00
parent b3b2868f55
commit 9bdb45be7c
21 changed files with 853 additions and 90 deletions

View File

@@ -4,7 +4,13 @@
*/
import { apiClient } from '../client'
import type { Proxy, CreateProxyRequest, UpdateProxyRequest, PaginatedResponse } from '@/types'
import type {
Proxy,
ProxyAccountSummary,
CreateProxyRequest,
UpdateProxyRequest,
PaginatedResponse
} from '@/types'
/**
* List all proxies with pagination
@@ -160,8 +166,8 @@ export async function getStats(id: number): Promise<{
* @param id - Proxy ID
* @returns List of accounts using the proxy
*/
export async function getProxyAccounts(id: number): Promise<PaginatedResponse<any>> {
const { data } = await apiClient.get<PaginatedResponse<any>>(`/admin/proxies/${id}/accounts`)
export async function getProxyAccounts(id: number): Promise<ProxyAccountSummary[]> {
const { data } = await apiClient.get<ProxyAccountSummary[]>(`/admin/proxies/${id}/accounts`)
return data
}
@@ -189,6 +195,17 @@ export async function batchCreate(
return data
}
export async function batchDelete(ids: number[]): Promise<{
deleted_ids: number[]
skipped: Array<{ id: number; reason: string }>
}> {
const { data } = await apiClient.post<{
deleted_ids: number[]
skipped: Array<{ id: number; reason: string }>
}>('/admin/proxies/batch-delete', { ids })
return data
}
export const proxiesAPI = {
list,
getAll,
@@ -201,7 +218,8 @@ export const proxiesAPI = {
testProxy,
getStats,
getProxyAccounts,
batchCreate
batchCreate,
batchDelete
}
export default proxiesAPI

View File

@@ -22,29 +22,36 @@
]"
@click="column.sortable && handleSort(column.key)"
>
<div class="flex items-center space-x-1">
<span>{{ column.label }}</span>
<span v-if="column.sortable" class="text-gray-400 dark:text-dark-500">
<svg
v-if="sortKey === column.key"
class="h-4 w-4"
:class="{ 'rotate-180 transform': sortOrder === 'desc' }"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z"
clip-rule="evenodd"
/>
</svg>
<svg v-else class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
/>
</svg>
</span>
</div>
<slot
:name="`header-${column.key}`"
:column="column"
:sort-key="sortKey"
:sort-order="sortOrder"
>
<div class="flex items-center space-x-1">
<span>{{ column.label }}</span>
<span v-if="column.sortable" class="text-gray-400 dark:text-dark-500">
<svg
v-if="sortKey === column.key"
class="h-4 w-4"
:class="{ 'rotate-180 transform': sortOrder === 'desc' }"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z"
clip-rule="evenodd"
/>
</svg>
<svg v-else class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
/>
</svg>
</span>
</div>
</slot>
</th>
</tr>
</thead>

View File

@@ -1627,11 +1627,29 @@ export default {
address: 'Address',
status: 'Status',
accounts: 'Accounts',
latency: 'Latency',
actions: 'Actions'
},
testConnection: 'Test Connection',
batchTest: 'Test All Proxies',
testFailed: 'Failed',
latencyFailed: 'Connection failed',
batchTestEmpty: 'No proxies available for testing',
batchTestDone: 'Batch test completed for {count} proxies',
batchTestFailed: 'Batch test failed',
batchDeleteAction: 'Delete',
batchDelete: 'Batch delete',
batchDeleteConfirm: 'Delete {count} selected proxies? In-use ones will be skipped.',
batchDeleteDone: 'Deleted {deleted} proxies, skipped {skipped}',
batchDeleteSkipped: 'Skipped {skipped} proxies',
batchDeleteFailed: 'Batch delete failed',
deleteBlockedInUse: 'This proxy is in use and cannot be deleted',
accountsTitle: 'Accounts using this IP',
accountsEmpty: 'No accounts are using this proxy',
accountsFailed: 'Failed to load accounts list',
accountName: 'Account',
accountPlatform: 'Platform',
accountNotes: 'Notes',
name: 'Name',
protocol: 'Protocol',
host: 'Host',

View File

@@ -1713,6 +1713,7 @@ export default {
address: '地址',
status: '状态',
accounts: '账号数',
latency: '延迟',
actions: '操作',
nameLabel: '名称',
namePlaceholder: '请输入代理名称',
@@ -1749,11 +1750,32 @@ export default {
enterProxyName: '请输入代理名称',
optionalAuth: '可选认证信息',
leaveEmptyToKeep: '留空保持不变',
form: {
hostPlaceholder: '请输入主机地址',
portPlaceholder: '请输入端口'
},
noProxiesYet: '暂无代理',
createFirstProxy: '添加您的第一个代理以开始使用。',
testConnection: '测试连接',
batchTest: '批量测试',
testFailed: '失败',
latencyFailed: '链接失败',
batchTestEmpty: '暂无可测试的代理',
batchTestDone: '批量测试完成,共测试 {count} 个代理',
batchTestFailed: '批量测试失败',
batchDeleteAction: '删除',
batchDelete: '批量删除',
batchDeleteConfirm: '确定删除选中的 {count} 个代理吗?已被账号使用的将自动跳过。',
batchDeleteDone: '已删除 {deleted} 个代理,跳过 {skipped} 个',
batchDeleteSkipped: '已跳过 {skipped} 个代理',
batchDeleteFailed: '批量删除失败',
deleteBlockedInUse: '该代理已有账号使用,无法删除',
accountsTitle: '使用该IP的账号',
accountsEmpty: '暂无账号使用此代理',
accountsFailed: '获取账号列表失败',
accountName: '账号名称',
accountPlatform: '所属平台',
accountNotes: '备注',
// Batch import
standardAdd: '标准添加',
batchAdd: '快捷添加',

View File

@@ -364,10 +364,21 @@ export interface Proxy {
password?: string | null
status: 'active' | 'inactive'
account_count?: number // Number of accounts using this proxy
latency_ms?: number
latency_status?: 'success' | 'failed'
latency_message?: string
created_at: string
updated_at: string
}
export interface ProxyAccountSummary {
id: number
name: string
platform: AccountPlatform
type: AccountType
notes?: string | null
}
// Gemini credentials structure for OAuth and API Key authentication
export interface GeminiCredentials {
// API Key authentication

View File

@@ -51,6 +51,24 @@
>
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
</button>
<button
@click="handleBatchTest"
:disabled="batchTesting || loading"
class="btn btn-secondary"
:title="t('admin.proxies.testConnection')"
>
<Icon name="play" size="md" class="mr-2" />
{{ t('admin.proxies.testConnection') }}
</button>
<button
@click="openBatchDelete"
:disabled="selectedCount === 0"
class="btn btn-danger"
:title="t('admin.proxies.batchDeleteAction')"
>
<Icon name="trash" size="md" class="mr-2" />
{{ t('admin.proxies.batchDeleteAction') }}
</button>
<button @click="showCreateModal = true" class="btn btn-primary">
<Icon name="plus" size="md" class="mr-2" />
{{ t('admin.proxies.createProxy') }}
@@ -61,6 +79,26 @@
<template #table>
<DataTable :columns="columns" :data="proxies" :loading="loading">
<template #header-select>
<input
type="checkbox"
class="h-4 w-4 cursor-pointer rounded border-gray-300 text-primary-600 focus:ring-primary-500"
:checked="allVisibleSelected"
@click.stop
@change="toggleSelectAllVisible($event)"
/>
</template>
<template #cell-select="{ row }">
<input
type="checkbox"
class="h-4 w-4 cursor-pointer rounded border-gray-300 text-primary-600 focus:ring-primary-500"
:checked="selectedProxyIds.has(row.id)"
@click.stop
@change="toggleSelectRow(row.id, $event)"
/>
</template>
<template #cell-name="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
</template>
@@ -79,17 +117,43 @@
<code class="code text-xs">{{ row.host }}:{{ row.port }}</code>
</template>
<template #cell-status="{ value }">
<span :class="['badge', value === 'active' ? 'badge-success' : 'badge-danger']">
{{ t('admin.accounts.status.' + value) }}
<template #cell-account_count="{ row, value }">
<button
v-if="(value || 0) > 0"
type="button"
class="inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-primary-700 hover:bg-gray-200 dark:bg-dark-600 dark:text-primary-300 dark:hover:bg-dark-500"
@click="openAccountsModal(row)"
>
{{ t('admin.groups.accountsCount', { count: value || 0 }) }}
</button>
<span
v-else
class="inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300"
>
{{ t('admin.groups.accountsCount', { count: 0 }) }}
</span>
</template>
<template #cell-account_count="{ value }">
<template #cell-latency="{ row }">
<span
class="inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300"
v-if="row.latency_status === 'failed'"
class="badge badge-danger"
:title="row.latency_message || undefined"
>
{{ t('admin.groups.accountsCount', { count: value || 0 }) }}
{{ t('admin.proxies.latencyFailed') }}
</span>
<span
v-else-if="typeof row.latency_ms === 'number'"
:class="['badge', row.latency_ms < 200 ? 'badge-success' : 'badge-warning']"
>
{{ row.latency_ms }}ms
</span>
<span v-else class="text-sm text-gray-400">-</span>
</template>
<template #cell-status="{ value }">
<span :class="['badge', value === 'active' ? 'badge-success' : 'badge-danger']">
{{ t('admin.accounts.status.' + value) }}
</span>
</template>
@@ -515,6 +579,63 @@
@confirm="confirmDelete"
@cancel="showDeleteDialog = false"
/>
<!-- Batch Delete Confirmation Dialog -->
<ConfirmDialog
:show="showBatchDeleteDialog"
:title="t('admin.proxies.batchDelete')"
:message="t('admin.proxies.batchDeleteConfirm', { count: selectedCount })"
:confirm-text="t('common.delete')"
:cancel-text="t('common.cancel')"
:danger="true"
@confirm="confirmBatchDelete"
@cancel="showBatchDeleteDialog = false"
/>
<!-- Proxy Accounts Dialog -->
<BaseDialog
:show="showAccountsModal"
:title="t('admin.proxies.accountsTitle', { name: accountsProxy?.name || '' })"
width="normal"
@close="closeAccountsModal"
>
<div v-if="accountsLoading" class="flex items-center justify-center py-8 text-sm text-gray-500">
<Icon name="refresh" size="md" class="mr-2 animate-spin" />
{{ t('common.loading') }}
</div>
<div v-else-if="proxyAccounts.length === 0" class="py-6 text-center text-sm text-gray-500">
{{ t('admin.proxies.accountsEmpty') }}
</div>
<div v-else class="max-h-80 overflow-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-dark-700">
<thead class="bg-gray-50 text-xs uppercase text-gray-500 dark:bg-dark-800 dark:text-dark-400">
<tr>
<th class="px-4 py-2 text-left">{{ t('admin.proxies.accountName') }}</th>
<th class="px-4 py-2 text-left">{{ t('admin.accounts.columns.platformType') }}</th>
<th class="px-4 py-2 text-left">{{ t('admin.proxies.accountNotes') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-900">
<tr v-for="account in proxyAccounts" :key="account.id">
<td class="px-4 py-2 font-medium text-gray-900 dark:text-white">{{ account.name }}</td>
<td class="px-4 py-2">
<PlatformTypeBadge :platform="account.platform" :type="account.type" />
</td>
<td class="px-4 py-2 text-gray-600 dark:text-gray-300">
{{ account.notes || '-' }}
</td>
</tr>
</tbody>
</table>
</div>
<template #footer>
<div class="flex justify-end">
<button @click="closeAccountsModal" class="btn btn-secondary">
{{ t('common.close') }}
</button>
</div>
</template>
</BaseDialog>
</AppLayout>
</template>
@@ -523,7 +644,7 @@ import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import type { Proxy, ProxyProtocol } from '@/types'
import type { Proxy, ProxyAccountSummary, ProxyProtocol } from '@/types'
import type { Column } from '@/components/common/types'
import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
@@ -534,15 +655,18 @@ import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue'
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
const { t } = useI18n()
const appStore = useAppStore()
const columns = computed<Column[]>(() => [
{ key: 'select', label: '', sortable: false },
{ key: 'name', label: t('admin.proxies.columns.name'), sortable: true },
{ key: 'protocol', label: t('admin.proxies.columns.protocol'), sortable: true },
{ key: 'address', label: t('admin.proxies.columns.address'), sortable: false },
{ key: 'account_count', label: t('admin.proxies.columns.accounts'), sortable: true },
{ key: 'latency', label: t('admin.proxies.columns.latency'), sortable: false },
{ key: 'status', label: t('admin.proxies.columns.status'), sortable: true },
{ key: 'actions', label: t('admin.proxies.columns.actions'), sortable: false }
])
@@ -592,11 +716,24 @@ const pagination = reactive({
const showCreateModal = ref(false)
const showEditModal = ref(false)
const showDeleteDialog = ref(false)
const showBatchDeleteDialog = ref(false)
const showAccountsModal = ref(false)
const submitting = ref(false)
const testingProxyIds = ref<Set<number>>(new Set())
const batchTesting = ref(false)
const selectedProxyIds = ref<Set<number>>(new Set())
const accountsProxy = ref<Proxy | null>(null)
const proxyAccounts = ref<ProxyAccountSummary[]>([])
const accountsLoading = ref(false)
const editingProxy = ref<Proxy | null>(null)
const deletingProxy = ref<Proxy | null>(null)
const selectedCount = computed(() => selectedProxyIds.value.size)
const allVisibleSelected = computed(() => {
if (proxies.value.length === 0) return false
return proxies.value.every((proxy) => selectedProxyIds.value.has(proxy.id))
})
// Batch import state
const createMode = ref<'standard' | 'batch'>('standard')
const batchInput = ref('')
@@ -641,6 +778,30 @@ const isAbortError = (error: unknown) => {
return maybeError.name === 'AbortError' || maybeError.code === 'ERR_CANCELED'
}
const toggleSelectRow = (id: number, event: Event) => {
const target = event.target as HTMLInputElement
const next = new Set(selectedProxyIds.value)
if (target.checked) {
next.add(id)
} else {
next.delete(id)
}
selectedProxyIds.value = next
}
const toggleSelectAllVisible = (event: Event) => {
const target = event.target as HTMLInputElement
const next = new Set(selectedProxyIds.value)
for (const proxy of proxies.value) {
if (target.checked) {
next.add(proxy.id)
} else {
next.delete(proxy.id)
}
}
selectedProxyIds.value = next
}
const loadProxies = async () => {
if (abortController) {
abortController.abort()
@@ -895,35 +1056,151 @@ const handleUpdateProxy = async () => {
}
}
const handleTestConnection = async (proxy: Proxy) => {
// Create new Set to trigger reactivity
testingProxyIds.value = new Set([...testingProxyIds.value, proxy.id])
const applyLatencyResult = (
proxyId: number,
result: { success: boolean; latency_ms?: number; message?: string }
) => {
const target = proxies.value.find((proxy) => proxy.id === proxyId)
if (!target) return
if (result.success) {
target.latency_status = 'success'
target.latency_ms = result.latency_ms
} else {
target.latency_status = 'failed'
target.latency_ms = undefined
}
target.latency_message = result.message
}
const startTestingProxy = (proxyId: number) => {
testingProxyIds.value = new Set([...testingProxyIds.value, proxyId])
}
const stopTestingProxy = (proxyId: number) => {
const next = new Set(testingProxyIds.value)
next.delete(proxyId)
testingProxyIds.value = next
}
const runProxyTest = async (proxyId: number, notify: boolean) => {
startTestingProxy(proxyId)
try {
const result = await adminAPI.proxies.testProxy(proxy.id)
if (result.success) {
const message = result.latency_ms
? t('admin.proxies.proxyWorkingWithLatency', { latency: result.latency_ms })
: t('admin.proxies.proxyWorking')
appStore.showSuccess(message)
} else {
appStore.showError(result.message || t('admin.proxies.proxyTestFailed'))
const result = await adminAPI.proxies.testProxy(proxyId)
applyLatencyResult(proxyId, result)
if (notify) {
if (result.success) {
const message = result.latency_ms
? t('admin.proxies.proxyWorkingWithLatency', { latency: result.latency_ms })
: t('admin.proxies.proxyWorking')
appStore.showSuccess(message)
} else {
appStore.showError(result.message || t('admin.proxies.proxyTestFailed'))
}
}
return result
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.proxies.failedToTest'))
const message = error.response?.data?.detail || t('admin.proxies.failedToTest')
applyLatencyResult(proxyId, { success: false, message })
if (notify) {
appStore.showError(message)
}
console.error('Error testing proxy:', error)
return null
} finally {
// Create new Set without this proxy id to trigger reactivity
const newSet = new Set(testingProxyIds.value)
newSet.delete(proxy.id)
testingProxyIds.value = newSet
stopTestingProxy(proxyId)
}
}
const handleTestConnection = async (proxy: Proxy) => {
await runProxyTest(proxy.id, true)
}
const fetchAllProxiesForBatch = async (): Promise<Proxy[]> => {
const pageSize = 200
const result: Proxy[] = []
let page = 1
let totalPages = 1
while (page <= totalPages) {
const response = await adminAPI.proxies.list(
page,
pageSize,
{
protocol: filters.protocol || undefined,
status: filters.status as any,
search: searchQuery.value || undefined
}
)
result.push(...response.items)
totalPages = response.pages || 1
page++
}
return result
}
const runBatchProxyTests = async (ids: number[]) => {
if (ids.length === 0) return
const concurrency = 5
let index = 0
const worker = async () => {
while (index < ids.length) {
const current = ids[index]
index++
await runProxyTest(current, false)
}
}
const workers = Array.from({ length: Math.min(concurrency, ids.length) }, () => worker())
await Promise.all(workers)
}
const handleBatchTest = async () => {
if (batchTesting.value) return
batchTesting.value = true
try {
let ids: number[] = []
if (selectedCount.value > 0) {
ids = Array.from(selectedProxyIds.value)
} else {
const allProxies = await fetchAllProxiesForBatch()
ids = allProxies.map((proxy) => proxy.id)
}
if (ids.length === 0) {
appStore.showInfo(t('admin.proxies.batchTestEmpty'))
return
}
await runBatchProxyTests(ids)
appStore.showSuccess(t('admin.proxies.batchTestDone', { count: ids.length }))
loadProxies()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.proxies.batchTestFailed'))
console.error('Error batch testing proxies:', error)
} finally {
batchTesting.value = false
}
}
const handleDelete = (proxy: Proxy) => {
if ((proxy.account_count || 0) > 0) {
appStore.showError(t('admin.proxies.deleteBlockedInUse'))
return
}
deletingProxy.value = proxy
showDeleteDialog.value = true
}
const openBatchDelete = () => {
if (selectedCount.value === 0) {
return
}
showBatchDeleteDialog.value = true
}
const confirmDelete = async () => {
if (!deletingProxy.value) return
@@ -931,6 +1208,11 @@ const confirmDelete = async () => {
await adminAPI.proxies.delete(deletingProxy.value.id)
appStore.showSuccess(t('admin.proxies.proxyDeleted'))
showDeleteDialog.value = false
if (selectedProxyIds.value.has(deletingProxy.value.id)) {
const next = new Set(selectedProxyIds.value)
next.delete(deletingProxy.value.id)
selectedProxyIds.value = next
}
deletingProxy.value = null
loadProxies()
} catch (error: any) {
@@ -939,6 +1221,55 @@ const confirmDelete = async () => {
}
}
const confirmBatchDelete = async () => {
const ids = Array.from(selectedProxyIds.value)
if (ids.length === 0) {
showBatchDeleteDialog.value = false
return
}
try {
const result = await adminAPI.proxies.batchDelete(ids)
const deleted = result.deleted_ids?.length || 0
const skipped = result.skipped?.length || 0
if (deleted > 0) {
appStore.showSuccess(t('admin.proxies.batchDeleteDone', { deleted, skipped }))
} else if (skipped > 0) {
appStore.showInfo(t('admin.proxies.batchDeleteSkipped', { skipped }))
}
selectedProxyIds.value = new Set()
showBatchDeleteDialog.value = false
loadProxies()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.proxies.batchDeleteFailed'))
console.error('Error batch deleting proxies:', error)
}
}
const openAccountsModal = async (proxy: Proxy) => {
accountsProxy.value = proxy
proxyAccounts.value = []
accountsLoading.value = true
showAccountsModal.value = true
try {
proxyAccounts.value = await adminAPI.proxies.getProxyAccounts(proxy.id)
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.proxies.accountsFailed'))
console.error('Error loading proxy accounts:', error)
} finally {
accountsLoading.value = false
}
}
const closeAccountsModal = () => {
showAccountsModal.value = false
accountsProxy.value = null
proxyAccounts.value = []
}
onMounted(() => {
loadProxies()
})