Show a semi-transparent blue rectangle overlay while dragging to select rows, matching the project's primary color theme with dark mode support. The box spans the full table width from drag start to current mouse position. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1862 lines
63 KiB
Vue
1862 lines
63 KiB
Vue
<template>
|
|
<AppLayout>
|
|
<TablePageLayout>
|
|
<template #filters>
|
|
<div class="flex flex-wrap items-center gap-3">
|
|
<!-- Left: Search + Filters -->
|
|
<div class="relative w-full sm:w-64">
|
|
<Icon
|
|
name="search"
|
|
size="md"
|
|
class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500"
|
|
/>
|
|
<input
|
|
v-model="searchQuery"
|
|
type="text"
|
|
:placeholder="t('admin.proxies.searchProxies')"
|
|
class="input pl-10"
|
|
@input="handleSearch"
|
|
/>
|
|
</div>
|
|
|
|
<div class="w-full sm:w-40">
|
|
<Select
|
|
v-model="filters.protocol"
|
|
:options="protocolOptions"
|
|
:placeholder="t('admin.proxies.allProtocols')"
|
|
@change="loadProxies"
|
|
/>
|
|
</div>
|
|
<div class="w-full sm:w-36">
|
|
<Select
|
|
v-model="filters.status"
|
|
:options="statusOptions"
|
|
:placeholder="t('admin.proxies.allStatus')"
|
|
@change="loadProxies"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Right: All action buttons -->
|
|
<div class="flex flex-1 flex-wrap items-center justify-end gap-2">
|
|
<button
|
|
@click="loadProxies"
|
|
:disabled="loading"
|
|
class="btn btn-secondary"
|
|
:title="t('common.refresh')"
|
|
>
|
|
<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="handleBatchQualityCheck"
|
|
:disabled="batchQualityChecking || loading"
|
|
class="btn btn-secondary"
|
|
:title="t('admin.proxies.batchQualityCheck')"
|
|
>
|
|
<Icon name="shield" size="md" class="mr-2" :class="batchQualityChecking ? 'animate-pulse' : ''" />
|
|
{{ t('admin.proxies.batchQualityCheck') }}
|
|
</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="showImportData = true" class="btn btn-secondary">
|
|
{{ t('admin.proxies.dataImport') }}
|
|
</button>
|
|
<button @click="showExportDataDialog = true" class="btn btn-secondary">
|
|
{{ selectedCount > 0 ? t('admin.proxies.dataExportSelected') : t('admin.proxies.dataExport') }}
|
|
</button>
|
|
<button @click="showCreateModal = true" class="btn btn-primary">
|
|
<Icon name="plus" size="md" class="mr-2" />
|
|
{{ t('admin.proxies.createProxy') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template #table>
|
|
<div ref="proxyTableRef">
|
|
<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>
|
|
|
|
<template #cell-protocol="{ value }">
|
|
<span
|
|
v-if="value"
|
|
:class="['badge', value.startsWith('socks5') ? 'badge-primary' : 'badge-gray']"
|
|
>
|
|
{{ value.toUpperCase() }}
|
|
</span>
|
|
<span v-else class="text-sm text-gray-400">-</span>
|
|
</template>
|
|
|
|
<template #cell-address="{ row }">
|
|
<div class="flex items-center gap-1.5">
|
|
<code class="code text-xs">{{ row.host }}:{{ row.port }}</code>
|
|
<div class="relative">
|
|
<button
|
|
type="button"
|
|
class="rounded p-0.5 text-gray-400 hover:text-primary-600 dark:hover:text-primary-400"
|
|
:title="t('admin.proxies.copyProxyUrl')"
|
|
@click.stop="copyProxyUrl(row)"
|
|
@contextmenu.prevent="toggleCopyMenu(row.id)"
|
|
>
|
|
<Icon name="copy" size="sm" />
|
|
</button>
|
|
<!-- 右键展开格式选择菜单 -->
|
|
<div
|
|
v-if="copyMenuProxyId === row.id"
|
|
class="absolute left-0 top-full z-50 mt-1 w-auto min-w-[180px] rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dark-500 dark:bg-dark-700"
|
|
>
|
|
<button
|
|
v-for="fmt in getCopyFormats(row)"
|
|
:key="fmt.label"
|
|
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-gray-100 dark:hover:bg-dark-600"
|
|
@click.stop="copyFormat(fmt.value)"
|
|
>
|
|
<span class="truncate font-mono text-gray-600 dark:text-gray-300">{{ fmt.label }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template #cell-auth="{ row }">
|
|
<div v-if="row.username || row.password" class="flex items-center gap-1.5">
|
|
<div class="flex flex-col text-xs">
|
|
<span v-if="row.username" class="text-gray-700 dark:text-gray-200">{{ row.username }}</span>
|
|
<span v-if="row.password" class="font-mono text-gray-500 dark:text-gray-400">
|
|
{{ visiblePasswordIds.has(row.id) ? row.password : '••••••' }}
|
|
</span>
|
|
</div>
|
|
<button
|
|
v-if="row.password"
|
|
type="button"
|
|
class="ml-1 rounded p-0.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
|
@click.stop="visiblePasswordIds.has(row.id) ? visiblePasswordIds.delete(row.id) : visiblePasswordIds.add(row.id)"
|
|
>
|
|
<Icon :name="visiblePasswordIds.has(row.id) ? 'eyeOff' : 'eye'" size="sm" />
|
|
</button>
|
|
</div>
|
|
<span v-else class="text-sm text-gray-400">-</span>
|
|
</template>
|
|
|
|
<template #cell-location="{ row }">
|
|
<div class="flex items-center gap-2">
|
|
<img
|
|
v-if="row.country_code"
|
|
:src="flagUrl(row.country_code)"
|
|
:alt="row.country || row.country_code"
|
|
class="h-4 w-6 rounded-sm"
|
|
/>
|
|
<span v-if="formatLocation(row)" class="text-sm text-gray-700 dark:text-gray-200">
|
|
{{ formatLocation(row) }}
|
|
</span>
|
|
<span v-else class="text-sm text-gray-400">-</span>
|
|
</div>
|
|
</template>
|
|
|
|
<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-latency="{ row }">
|
|
<div class="flex flex-col gap-1">
|
|
<span
|
|
v-if="row.latency_status === 'failed'"
|
|
class="badge badge-danger"
|
|
:title="row.latency_message || undefined"
|
|
>
|
|
{{ 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>
|
|
<div
|
|
v-if="typeof row.quality_checked === 'number'"
|
|
class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400"
|
|
:title="row.quality_summary || undefined"
|
|
>
|
|
<span>{{ t('admin.proxies.qualityInline', { grade: row.quality_grade || '-', score: row.quality_score ?? '-' }) }}</span>
|
|
<span class="badge" :class="qualityOverallClass(row.quality_status)">
|
|
{{ qualityOverallLabel(row.quality_status) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template #cell-status="{ value }">
|
|
<span :class="['badge', value === 'active' ? 'badge-success' : 'badge-danger']">
|
|
{{ t('admin.accounts.status.' + value) }}
|
|
</span>
|
|
</template>
|
|
|
|
<template #cell-actions="{ row }">
|
|
<div class="flex items-center gap-1">
|
|
<button
|
|
@click="handleTestConnection(row)"
|
|
:disabled="testingProxyIds.has(row.id)"
|
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-emerald-50 hover:text-emerald-600 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-emerald-900/20 dark:hover:text-emerald-400"
|
|
>
|
|
<svg
|
|
v-if="testingProxyIds.has(row.id)"
|
|
class="h-4 w-4 animate-spin"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<circle
|
|
class="opacity-25"
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
stroke="currentColor"
|
|
stroke-width="4"
|
|
></circle>
|
|
<path
|
|
class="opacity-75"
|
|
fill="currentColor"
|
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
></path>
|
|
</svg>
|
|
<Icon v-else name="checkCircle" size="sm" />
|
|
<span class="text-xs">{{ t('admin.proxies.testConnection') }}</span>
|
|
</button>
|
|
<button
|
|
@click="handleQualityCheck(row)"
|
|
:disabled="qualityCheckingProxyIds.has(row.id)"
|
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
|
>
|
|
<svg
|
|
v-if="qualityCheckingProxyIds.has(row.id)"
|
|
class="h-4 w-4 animate-spin"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<circle
|
|
class="opacity-25"
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
stroke="currentColor"
|
|
stroke-width="4"
|
|
></circle>
|
|
<path
|
|
class="opacity-75"
|
|
fill="currentColor"
|
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
></path>
|
|
</svg>
|
|
<Icon v-else name="shield" size="sm" />
|
|
<span class="text-xs">{{ t('admin.proxies.qualityCheck') }}</span>
|
|
</button>
|
|
<button
|
|
@click="handleEdit(row)"
|
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
|
|
>
|
|
<Icon name="edit" size="sm" />
|
|
<span class="text-xs">{{ t('common.edit') }}</span>
|
|
</button>
|
|
<button
|
|
@click="handleDelete(row)"
|
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
|
>
|
|
<Icon name="trash" size="sm" />
|
|
<span class="text-xs">{{ t('common.delete') }}</span>
|
|
</button>
|
|
</div>
|
|
</template>
|
|
|
|
<template #empty>
|
|
<EmptyState
|
|
:title="t('admin.proxies.noProxiesYet')"
|
|
:description="t('admin.proxies.createFirstProxy')"
|
|
:action-text="t('admin.proxies.createProxy')"
|
|
@action="showCreateModal = true"
|
|
/>
|
|
</template>
|
|
</DataTable>
|
|
</div>
|
|
</template>
|
|
|
|
<template #pagination>
|
|
<Pagination
|
|
v-if="pagination.total > 0"
|
|
:page="pagination.page"
|
|
:total="pagination.total"
|
|
:page-size="pagination.page_size"
|
|
@update:page="handlePageChange"
|
|
@update:pageSize="handlePageSizeChange"
|
|
/>
|
|
</template>
|
|
</TablePageLayout>
|
|
|
|
<!-- Create Proxy Modal -->
|
|
<BaseDialog
|
|
:show="showCreateModal"
|
|
:title="t('admin.proxies.createProxy')"
|
|
width="normal"
|
|
@close="closeCreateModal"
|
|
>
|
|
<!-- Tab Switch -->
|
|
<div class="mb-6 flex border-b border-gray-200 dark:border-dark-600">
|
|
<button
|
|
type="button"
|
|
@click="createMode = 'standard'"
|
|
:class="[
|
|
'-mb-px border-b-2 px-4 py-2 text-sm font-medium transition-colors',
|
|
createMode === 'standard'
|
|
? 'border-primary-500 text-primary-600 dark:text-primary-400'
|
|
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
|
]"
|
|
>
|
|
<Icon name="plus" size="sm" class="mr-1.5 inline" />
|
|
{{ t('admin.proxies.standardAdd') }}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
@click="createMode = 'batch'"
|
|
:class="[
|
|
'-mb-px border-b-2 px-4 py-2 text-sm font-medium transition-colors',
|
|
createMode === 'batch'
|
|
? 'border-primary-500 text-primary-600 dark:text-primary-400'
|
|
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
|
]"
|
|
>
|
|
<svg
|
|
class="mr-1.5 inline h-4 w-4"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 010 3.75H5.625a1.875 1.875 0 010-3.75z"
|
|
/>
|
|
</svg>
|
|
{{ t('admin.proxies.batchAdd') }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Standard Add Form -->
|
|
<form
|
|
v-if="createMode === 'standard'"
|
|
id="create-proxy-form"
|
|
@submit.prevent="handleCreateProxy"
|
|
class="space-y-5"
|
|
>
|
|
<div>
|
|
<label class="input-label">{{ t('admin.proxies.name') }}</label>
|
|
<input
|
|
v-model="createForm.name"
|
|
type="text"
|
|
required
|
|
class="input"
|
|
:placeholder="t('admin.proxies.enterProxyName')"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label class="input-label">{{ t('admin.proxies.protocol') }}</label>
|
|
<Select v-model="createForm.protocol" :options="protocolSelectOptions" />
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label class="input-label">{{ t('admin.proxies.host') }}</label>
|
|
<input
|
|
v-model="createForm.host"
|
|
type="text"
|
|
required
|
|
:placeholder="t('admin.proxies.form.hostPlaceholder')"
|
|
class="input"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label class="input-label">{{ t('admin.proxies.port') }}</label>
|
|
<input
|
|
v-model.number="createForm.port"
|
|
type="number"
|
|
required
|
|
min="1"
|
|
max="65535"
|
|
:placeholder="t('admin.proxies.form.portPlaceholder')"
|
|
class="input"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label class="input-label">{{ t('admin.proxies.username') }}</label>
|
|
<input
|
|
v-model="createForm.username"
|
|
type="text"
|
|
class="input"
|
|
:placeholder="t('admin.proxies.optionalAuth')"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label class="input-label">{{ t('admin.proxies.password') }}</label>
|
|
<div class="relative">
|
|
<input
|
|
v-model="createForm.password"
|
|
:type="createPasswordVisible ? 'text' : 'password'"
|
|
class="input pr-10"
|
|
:placeholder="t('admin.proxies.optionalAuth')"
|
|
/>
|
|
<button
|
|
type="button"
|
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
|
@click="createPasswordVisible = !createPasswordVisible"
|
|
>
|
|
<Icon :name="createPasswordVisible ? 'eyeOff' : 'eye'" size="md" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
</form>
|
|
|
|
<!-- Batch Add Form -->
|
|
<div v-else class="space-y-5">
|
|
<div>
|
|
<label class="input-label">{{ t('admin.proxies.batchInput') }}</label>
|
|
<textarea
|
|
v-model="batchInput"
|
|
rows="10"
|
|
class="input font-mono text-sm"
|
|
:placeholder="t('admin.proxies.batchInputPlaceholder')"
|
|
@input="parseBatchInput"
|
|
></textarea>
|
|
<p class="input-hint mt-2">
|
|
{{ t('admin.proxies.batchInputHint') }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Parse Result -->
|
|
<div v-if="batchParseResult.total > 0" class="rounded-lg bg-gray-50 p-4 dark:bg-dark-700">
|
|
<div class="flex items-center gap-4 text-sm">
|
|
<div class="flex items-center gap-1.5">
|
|
<Icon name="checkCircle" size="sm" :stroke-width="2" class="text-primary-500" />
|
|
<span class="text-gray-700 dark:text-gray-300">
|
|
{{ t('admin.proxies.parsedCount', { count: batchParseResult.valid }) }}
|
|
</span>
|
|
</div>
|
|
<div v-if="batchParseResult.invalid > 0" class="flex items-center gap-1.5">
|
|
<Icon
|
|
name="exclamationCircle"
|
|
size="sm"
|
|
:stroke-width="2"
|
|
class="text-amber-500"
|
|
/>
|
|
<span class="text-amber-600 dark:text-amber-400">
|
|
{{ t('admin.proxies.invalidCount', { count: batchParseResult.invalid }) }}
|
|
</span>
|
|
</div>
|
|
<div v-if="batchParseResult.duplicate > 0" class="flex items-center gap-1.5">
|
|
<svg
|
|
class="h-4 w-4 text-gray-400"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75"
|
|
/>
|
|
</svg>
|
|
<span class="text-gray-500 dark:text-gray-400">
|
|
{{ t('admin.proxies.duplicateCount', { count: batchParseResult.duplicate }) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<template #footer>
|
|
<div class="flex justify-end gap-3">
|
|
<button @click="closeCreateModal" type="button" class="btn btn-secondary">
|
|
{{ t('common.cancel') }}
|
|
</button>
|
|
<button
|
|
v-if="createMode === 'standard'"
|
|
type="submit"
|
|
form="create-proxy-form"
|
|
:disabled="submitting"
|
|
class="btn btn-primary"
|
|
>
|
|
<svg
|
|
v-if="submitting"
|
|
class="-ml-1 mr-2 h-4 w-4 animate-spin"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<circle
|
|
class="opacity-25"
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
stroke="currentColor"
|
|
stroke-width="4"
|
|
></circle>
|
|
<path
|
|
class="opacity-75"
|
|
fill="currentColor"
|
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
></path>
|
|
</svg>
|
|
{{ submitting ? t('admin.proxies.creating') : t('common.create') }}
|
|
</button>
|
|
<button
|
|
v-else
|
|
@click="handleBatchCreate"
|
|
type="button"
|
|
:disabled="submitting || batchParseResult.valid === 0"
|
|
class="btn btn-primary"
|
|
>
|
|
<svg
|
|
v-if="submitting"
|
|
class="-ml-1 mr-2 h-4 w-4 animate-spin"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<circle
|
|
class="opacity-25"
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
stroke="currentColor"
|
|
stroke-width="4"
|
|
></circle>
|
|
<path
|
|
class="opacity-75"
|
|
fill="currentColor"
|
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
></path>
|
|
</svg>
|
|
{{
|
|
submitting
|
|
? t('admin.proxies.importing')
|
|
: t('admin.proxies.importProxies', { count: batchParseResult.valid })
|
|
}}
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</BaseDialog>
|
|
|
|
<!-- Edit Proxy Modal -->
|
|
<BaseDialog
|
|
:show="showEditModal"
|
|
:title="t('admin.proxies.editProxy')"
|
|
width="normal"
|
|
@close="closeEditModal"
|
|
>
|
|
<form
|
|
v-if="editingProxy"
|
|
id="edit-proxy-form"
|
|
@submit.prevent="handleUpdateProxy"
|
|
class="space-y-5"
|
|
>
|
|
<div>
|
|
<label class="input-label">{{ t('admin.proxies.name') }}</label>
|
|
<input v-model="editForm.name" type="text" required class="input" />
|
|
</div>
|
|
<div>
|
|
<label class="input-label">{{ t('admin.proxies.protocol') }}</label>
|
|
<Select v-model="editForm.protocol" :options="protocolSelectOptions" />
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label class="input-label">{{ t('admin.proxies.host') }}</label>
|
|
<input v-model="editForm.host" type="text" required class="input" />
|
|
</div>
|
|
<div>
|
|
<label class="input-label">{{ t('admin.proxies.port') }}</label>
|
|
<input
|
|
v-model.number="editForm.port"
|
|
type="number"
|
|
required
|
|
min="1"
|
|
max="65535"
|
|
class="input"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label class="input-label">{{ t('admin.proxies.username') }}</label>
|
|
<input v-model="editForm.username" type="text" class="input" />
|
|
</div>
|
|
<div>
|
|
<label class="input-label">{{ t('admin.proxies.password') }}</label>
|
|
<div class="relative">
|
|
<input
|
|
v-model="editForm.password"
|
|
:type="editPasswordVisible ? 'text' : 'password'"
|
|
:placeholder="t('admin.proxies.leaveEmptyToKeep')"
|
|
class="input pr-10"
|
|
@input="editPasswordDirty = true"
|
|
/>
|
|
<button
|
|
type="button"
|
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
|
@click="editPasswordVisible = !editPasswordVisible"
|
|
>
|
|
<Icon :name="editPasswordVisible ? 'eyeOff' : 'eye'" size="md" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label class="input-label">{{ t('admin.proxies.status') }}</label>
|
|
<Select v-model="editForm.status" :options="editStatusOptions" />
|
|
</div>
|
|
|
|
</form>
|
|
|
|
<template #footer>
|
|
<div class="flex justify-end gap-3">
|
|
<button @click="closeEditModal" type="button" class="btn btn-secondary">
|
|
{{ t('common.cancel') }}
|
|
</button>
|
|
<button
|
|
v-if="editingProxy"
|
|
type="submit"
|
|
form="edit-proxy-form"
|
|
:disabled="submitting"
|
|
class="btn btn-primary"
|
|
>
|
|
<svg
|
|
v-if="submitting"
|
|
class="-ml-1 mr-2 h-4 w-4 animate-spin"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<circle
|
|
class="opacity-25"
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
stroke="currentColor"
|
|
stroke-width="4"
|
|
></circle>
|
|
<path
|
|
class="opacity-75"
|
|
fill="currentColor"
|
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
></path>
|
|
</svg>
|
|
{{ submitting ? t('admin.proxies.updating') : t('common.update') }}
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</BaseDialog>
|
|
|
|
<!-- Delete Confirmation Dialog -->
|
|
<ConfirmDialog
|
|
:show="showDeleteDialog"
|
|
:title="t('admin.proxies.deleteProxy')"
|
|
:message="t('admin.proxies.deleteConfirm', { name: deletingProxy?.name })"
|
|
:confirm-text="t('common.delete')"
|
|
:cancel-text="t('common.cancel')"
|
|
:danger="true"
|
|
@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"
|
|
/>
|
|
<ConfirmDialog
|
|
:show="showExportDataDialog"
|
|
:title="t('admin.proxies.dataExport')"
|
|
:message="t('admin.proxies.dataExportConfirmMessage')"
|
|
:confirm-text="t('admin.proxies.dataExportConfirm')"
|
|
:cancel-text="t('common.cancel')"
|
|
@confirm="handleExportData"
|
|
@cancel="showExportDataDialog = false"
|
|
/>
|
|
|
|
<ImportDataModal
|
|
:show="showImportData"
|
|
@close="showImportData = false"
|
|
@imported="handleDataImported"
|
|
/>
|
|
|
|
<BaseDialog
|
|
:show="showQualityReportDialog"
|
|
:title="t('admin.proxies.qualityReportTitle')"
|
|
width="normal"
|
|
@close="closeQualityReportDialog"
|
|
>
|
|
<div v-if="qualityReport" class="space-y-4">
|
|
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-700">
|
|
<div class="flex items-center justify-between gap-4">
|
|
<div>
|
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
|
{{ qualityReportProxy?.name || '-' }}
|
|
</div>
|
|
<div class="mt-1 text-sm text-gray-700 dark:text-gray-200">
|
|
{{ qualityReport.summary }}
|
|
</div>
|
|
</div>
|
|
<div class="text-right">
|
|
<div class="text-2xl font-semibold text-gray-900 dark:text-white">
|
|
{{ qualityReport.score }}
|
|
</div>
|
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
|
{{ t('admin.proxies.qualityGrade', { grade: qualityReport.grade }) }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="mt-3 grid grid-cols-2 gap-2 text-xs text-gray-600 dark:text-gray-300">
|
|
<div>{{ t('admin.proxies.qualityExitIP') }}: {{ qualityReport.exit_ip || '-' }}</div>
|
|
<div>{{ t('admin.proxies.qualityCountry') }}: {{ qualityReport.country || '-' }}</div>
|
|
<div>
|
|
{{ t('admin.proxies.qualityBaseLatency') }}:
|
|
{{ typeof qualityReport.base_latency_ms === 'number' ? `${qualityReport.base_latency_ms}ms` : '-' }}
|
|
</div>
|
|
<div>{{ t('admin.proxies.qualityCheckedAt') }}: {{ new Date(qualityReport.checked_at * 1000).toLocaleString() }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="max-h-80 overflow-auto rounded-lg border border-gray-200 dark:border-dark-600">
|
|
<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-3 py-2 text-left">{{ t('admin.proxies.qualityTableTarget') }}</th>
|
|
<th class="px-3 py-2 text-left">{{ t('admin.proxies.qualityTableStatus') }}</th>
|
|
<th class="px-3 py-2 text-left">HTTP</th>
|
|
<th class="px-3 py-2 text-left">{{ t('admin.proxies.qualityTableLatency') }}</th>
|
|
<th class="px-3 py-2 text-left">{{ t('admin.proxies.qualityTableMessage') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-900">
|
|
<tr v-for="item in qualityReport.items" :key="item.target">
|
|
<td class="px-3 py-2 text-gray-900 dark:text-white">{{ qualityTargetLabel(item.target) }}</td>
|
|
<td class="px-3 py-2">
|
|
<span class="badge" :class="qualityStatusClass(item.status)">{{ qualityStatusLabel(item.status) }}</span>
|
|
</td>
|
|
<td class="px-3 py-2 text-gray-600 dark:text-gray-300">{{ item.http_status ?? '-' }}</td>
|
|
<td class="px-3 py-2 text-gray-600 dark:text-gray-300">
|
|
{{ typeof item.latency_ms === 'number' ? `${item.latency_ms}ms` : '-' }}
|
|
</td>
|
|
<td class="px-3 py-2 text-gray-600 dark:text-gray-300">
|
|
<span>{{ item.message || '-' }}</span>
|
|
<span v-if="item.cf_ray" class="ml-1 text-xs text-gray-400">(cf-ray: {{ item.cf_ray }})</span>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<template #footer>
|
|
<div class="flex justify-end">
|
|
<button @click="closeQualityReportDialog" class="btn btn-secondary">
|
|
{{ t('common.close') }}
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</BaseDialog>
|
|
|
|
<!-- 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>
|
|
|
|
<script setup lang="ts">
|
|
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, ProxyAccountSummary, ProxyProtocol, ProxyQualityCheckResult } from '@/types'
|
|
import type { Column } from '@/components/common/types'
|
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
|
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
|
import DataTable from '@/components/common/DataTable.vue'
|
|
import Pagination from '@/components/common/Pagination.vue'
|
|
import BaseDialog from '@/components/common/BaseDialog.vue'
|
|
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
|
import EmptyState from '@/components/common/EmptyState.vue'
|
|
import ImportDataModal from '@/components/admin/proxy/ImportDataModal.vue'
|
|
import Select from '@/components/common/Select.vue'
|
|
import Icon from '@/components/icons/Icon.vue'
|
|
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
|
|
import { useClipboard } from '@/composables/useClipboard'
|
|
import { useSwipeSelect } from '@/composables/useSwipeSelect'
|
|
|
|
const { t } = useI18n()
|
|
const appStore = useAppStore()
|
|
const { copyToClipboard } = useClipboard()
|
|
|
|
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: 'auth', label: t('admin.proxies.columns.auth'), sortable: false },
|
|
{ key: 'location', label: t('admin.proxies.columns.location'), 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 }
|
|
])
|
|
|
|
// Filter options
|
|
const protocolOptions = computed(() => [
|
|
{ value: '', label: t('admin.proxies.allProtocols') },
|
|
{ value: 'http', label: 'HTTP' },
|
|
{ value: 'https', label: 'HTTPS' },
|
|
{ value: 'socks5', label: 'SOCKS5' },
|
|
{ value: 'socks5h', label: 'SOCKS5H' }
|
|
])
|
|
|
|
const statusOptions = computed(() => [
|
|
{ value: '', label: t('admin.proxies.allStatus') },
|
|
{ value: 'active', label: t('admin.accounts.status.active') },
|
|
{ value: 'inactive', label: t('admin.accounts.status.inactive') }
|
|
])
|
|
|
|
// Form options
|
|
const protocolSelectOptions = computed(() => [
|
|
{ value: 'http', label: t('admin.proxies.protocols.http') },
|
|
{ value: 'https', label: t('admin.proxies.protocols.https') },
|
|
{ value: 'socks5', label: t('admin.proxies.protocols.socks5') },
|
|
{ value: 'socks5h', label: t('admin.proxies.protocols.socks5h') }
|
|
])
|
|
|
|
const editStatusOptions = computed(() => [
|
|
{ value: 'active', label: t('admin.accounts.status.active') },
|
|
{ value: 'inactive', label: t('admin.accounts.status.inactive') }
|
|
])
|
|
|
|
const proxies = ref<Proxy[]>([])
|
|
const visiblePasswordIds = reactive(new Set<number>())
|
|
const copyMenuProxyId = ref<number | null>(null)
|
|
const loading = ref(false)
|
|
const searchQuery = ref('')
|
|
const filters = reactive({
|
|
protocol: '',
|
|
status: ''
|
|
})
|
|
const pagination = reactive({
|
|
page: 1,
|
|
page_size: 20,
|
|
total: 0,
|
|
pages: 0
|
|
})
|
|
|
|
const showCreateModal = ref(false)
|
|
const createPasswordVisible = ref(false)
|
|
const showEditModal = ref(false)
|
|
const editPasswordVisible = ref(false)
|
|
const editPasswordDirty = ref(false)
|
|
const showImportData = ref(false)
|
|
const showDeleteDialog = ref(false)
|
|
const showBatchDeleteDialog = ref(false)
|
|
const showExportDataDialog = ref(false)
|
|
const showAccountsModal = ref(false)
|
|
const submitting = ref(false)
|
|
const exportingData = ref(false)
|
|
const testingProxyIds = ref<Set<number>>(new Set())
|
|
const qualityCheckingProxyIds = ref<Set<number>>(new Set())
|
|
const batchTesting = ref(false)
|
|
const batchQualityChecking = ref(false)
|
|
const selectedProxyIds = ref<Set<number>>(new Set())
|
|
const proxyTableRef = ref<HTMLElement | null>(null)
|
|
useSwipeSelect(proxyTableRef, {
|
|
isSelected: (id) => selectedProxyIds.value.has(id),
|
|
select: (id) => { const next = new Set(selectedProxyIds.value); next.add(id); selectedProxyIds.value = next },
|
|
deselect: (id) => { const next = new Set(selectedProxyIds.value); next.delete(id); selectedProxyIds.value = next }
|
|
})
|
|
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 showQualityReportDialog = ref(false)
|
|
const qualityReportProxy = ref<Proxy | null>(null)
|
|
const qualityReport = ref<ProxyQualityCheckResult | 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('')
|
|
const batchParseResult = reactive({
|
|
total: 0,
|
|
valid: 0,
|
|
invalid: 0,
|
|
duplicate: 0,
|
|
proxies: [] as Array<{
|
|
protocol: ProxyProtocol
|
|
host: string
|
|
port: number
|
|
username: string
|
|
password: string
|
|
}>
|
|
})
|
|
|
|
const createForm = reactive({
|
|
name: '',
|
|
protocol: 'http' as ProxyProtocol,
|
|
host: '',
|
|
port: 8080,
|
|
username: '',
|
|
password: ''
|
|
})
|
|
|
|
const editForm = reactive({
|
|
name: '',
|
|
protocol: 'http' as ProxyProtocol,
|
|
host: '',
|
|
port: 8080,
|
|
username: '',
|
|
password: '',
|
|
status: 'active' as 'active' | 'inactive'
|
|
})
|
|
|
|
let abortController: AbortController | null = null
|
|
|
|
const isAbortError = (error: unknown) => {
|
|
if (!error || typeof error !== 'object') return false
|
|
const maybeError = error as { name?: string; code?: string }
|
|
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()
|
|
}
|
|
const currentAbortController = new AbortController()
|
|
abortController = currentAbortController
|
|
loading.value = true
|
|
try {
|
|
const response = await adminAPI.proxies.list(pagination.page, pagination.page_size, {
|
|
protocol: filters.protocol || undefined,
|
|
status: filters.status as any,
|
|
search: searchQuery.value || undefined
|
|
}, { signal: currentAbortController.signal })
|
|
if (currentAbortController.signal.aborted || abortController !== currentAbortController) {
|
|
return
|
|
}
|
|
proxies.value = response.items
|
|
pagination.total = response.total
|
|
pagination.pages = response.pages
|
|
} catch (error) {
|
|
if (isAbortError(error)) {
|
|
return
|
|
}
|
|
appStore.showError(t('admin.proxies.failedToLoad'))
|
|
console.error('Error loading proxies:', error)
|
|
} finally {
|
|
if (abortController === currentAbortController) {
|
|
loading.value = false
|
|
abortController = null
|
|
}
|
|
}
|
|
}
|
|
|
|
let searchTimeout: ReturnType<typeof setTimeout>
|
|
const handleSearch = () => {
|
|
clearTimeout(searchTimeout)
|
|
searchTimeout = setTimeout(() => {
|
|
pagination.page = 1
|
|
loadProxies()
|
|
}, 300)
|
|
}
|
|
|
|
const handlePageChange = (page: number) => {
|
|
pagination.page = page
|
|
loadProxies()
|
|
}
|
|
|
|
const handlePageSizeChange = (pageSize: number) => {
|
|
pagination.page_size = pageSize
|
|
pagination.page = 1
|
|
loadProxies()
|
|
}
|
|
|
|
const closeCreateModal = () => {
|
|
showCreateModal.value = false
|
|
createMode.value = 'standard'
|
|
createForm.name = ''
|
|
createForm.protocol = 'http'
|
|
createForm.host = ''
|
|
createForm.port = 8080
|
|
createForm.username = ''
|
|
createForm.password = ''
|
|
createPasswordVisible.value = false
|
|
batchInput.value = ''
|
|
batchParseResult.total = 0
|
|
batchParseResult.valid = 0
|
|
batchParseResult.invalid = 0
|
|
batchParseResult.duplicate = 0
|
|
batchParseResult.proxies = []
|
|
}
|
|
|
|
const handleDataImported = () => {
|
|
showImportData.value = false
|
|
loadProxies()
|
|
}
|
|
|
|
// Parse proxy URL: protocol://user:pass@host:port or protocol://host:port
|
|
const parseProxyUrl = (
|
|
line: string
|
|
): {
|
|
protocol: ProxyProtocol
|
|
host: string
|
|
port: number
|
|
username: string
|
|
password: string
|
|
} | null => {
|
|
const trimmed = line.trim()
|
|
if (!trimmed) return null
|
|
|
|
// Regex to parse proxy URL (supports http, https, socks5, socks5h)
|
|
const regex = /^(https?|socks5h?):\/\/(?:([^:@]+):([^@]+)@)?([^:]+):(\d+)$/i
|
|
const match = trimmed.match(regex)
|
|
|
|
if (!match) return null
|
|
|
|
const [, protocol, username, password, host, port] = match
|
|
const portNum = parseInt(port, 10)
|
|
|
|
if (portNum < 1 || portNum > 65535) return null
|
|
|
|
return {
|
|
protocol: protocol.toLowerCase() as ProxyProtocol,
|
|
host: host.trim(),
|
|
port: portNum,
|
|
username: username?.trim() || '',
|
|
password: password?.trim() || ''
|
|
}
|
|
}
|
|
|
|
const parseBatchInput = () => {
|
|
const lines = batchInput.value.split('\n').filter((l) => l.trim())
|
|
const seen = new Set<string>()
|
|
const proxies: typeof batchParseResult.proxies = []
|
|
let invalid = 0
|
|
let duplicate = 0
|
|
|
|
for (const line of lines) {
|
|
const parsed = parseProxyUrl(line)
|
|
if (!parsed) {
|
|
invalid++
|
|
continue
|
|
}
|
|
|
|
// Check for duplicates (same host:port:username:password)
|
|
const key = `${parsed.host}:${parsed.port}:${parsed.username}:${parsed.password}`
|
|
if (seen.has(key)) {
|
|
duplicate++
|
|
continue
|
|
}
|
|
seen.add(key)
|
|
proxies.push(parsed)
|
|
}
|
|
|
|
batchParseResult.total = lines.length
|
|
batchParseResult.valid = proxies.length
|
|
batchParseResult.invalid = invalid
|
|
batchParseResult.duplicate = duplicate
|
|
batchParseResult.proxies = proxies
|
|
}
|
|
|
|
const handleBatchCreate = async () => {
|
|
if (batchParseResult.valid === 0) return
|
|
|
|
submitting.value = true
|
|
try {
|
|
const result = await adminAPI.proxies.batchCreate(batchParseResult.proxies)
|
|
const created = result.created || 0
|
|
const skipped = result.skipped || 0
|
|
|
|
if (created > 0) {
|
|
appStore.showSuccess(t('admin.proxies.batchImportSuccess', { created, skipped }))
|
|
} else {
|
|
appStore.showInfo(t('admin.proxies.batchImportAllSkipped', { skipped }))
|
|
}
|
|
|
|
closeCreateModal()
|
|
loadProxies()
|
|
} catch (error: any) {
|
|
appStore.showError(error.response?.data?.detail || t('admin.proxies.failedToImport'))
|
|
console.error('Error batch creating proxies:', error)
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
const handleCreateProxy = async () => {
|
|
if (!createForm.name.trim()) {
|
|
appStore.showError(t('admin.proxies.nameRequired'))
|
|
return
|
|
}
|
|
if (!createForm.host.trim()) {
|
|
appStore.showError(t('admin.proxies.hostRequired'))
|
|
return
|
|
}
|
|
if (createForm.port < 1 || createForm.port > 65535) {
|
|
appStore.showError(t('admin.proxies.portInvalid'))
|
|
return
|
|
}
|
|
submitting.value = true
|
|
try {
|
|
await adminAPI.proxies.create({
|
|
name: createForm.name.trim(),
|
|
protocol: createForm.protocol,
|
|
host: createForm.host.trim(),
|
|
port: createForm.port,
|
|
username: createForm.username.trim() || null,
|
|
password: createForm.password.trim() || null
|
|
})
|
|
appStore.showSuccess(t('admin.proxies.proxyCreated'))
|
|
closeCreateModal()
|
|
loadProxies()
|
|
} catch (error: any) {
|
|
appStore.showError(error.response?.data?.detail || t('admin.proxies.failedToCreate'))
|
|
console.error('Error creating proxy:', error)
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
const handleEdit = (proxy: Proxy) => {
|
|
editingProxy.value = proxy
|
|
editForm.name = proxy.name
|
|
editForm.protocol = proxy.protocol
|
|
editForm.host = proxy.host
|
|
editForm.port = proxy.port
|
|
editForm.username = proxy.username || ''
|
|
editForm.password = proxy.password || ''
|
|
editForm.status = proxy.status
|
|
editPasswordVisible.value = false
|
|
editPasswordDirty.value = false
|
|
showEditModal.value = true
|
|
}
|
|
|
|
const closeEditModal = () => {
|
|
showEditModal.value = false
|
|
editingProxy.value = null
|
|
editPasswordVisible.value = false
|
|
editPasswordDirty.value = false
|
|
}
|
|
|
|
const handleUpdateProxy = async () => {
|
|
if (!editingProxy.value) return
|
|
if (!editForm.name.trim()) {
|
|
appStore.showError(t('admin.proxies.nameRequired'))
|
|
return
|
|
}
|
|
if (!editForm.host.trim()) {
|
|
appStore.showError(t('admin.proxies.hostRequired'))
|
|
return
|
|
}
|
|
if (editForm.port < 1 || editForm.port > 65535) {
|
|
appStore.showError(t('admin.proxies.portInvalid'))
|
|
return
|
|
}
|
|
|
|
submitting.value = true
|
|
try {
|
|
const updateData: any = {
|
|
name: editForm.name.trim(),
|
|
protocol: editForm.protocol,
|
|
host: editForm.host.trim(),
|
|
port: editForm.port,
|
|
username: editForm.username.trim() || null,
|
|
status: editForm.status
|
|
}
|
|
|
|
// Only include password if user actually modified the field
|
|
if (editPasswordDirty.value) {
|
|
updateData.password = editForm.password.trim() || null
|
|
}
|
|
|
|
await adminAPI.proxies.update(editingProxy.value.id, updateData)
|
|
appStore.showSuccess(t('admin.proxies.proxyUpdated'))
|
|
closeEditModal()
|
|
loadProxies()
|
|
} catch (error: any) {
|
|
appStore.showError(error.response?.data?.detail || t('admin.proxies.failedToUpdate'))
|
|
console.error('Error updating proxy:', error)
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
const applyLatencyResult = (
|
|
proxyId: number,
|
|
result: {
|
|
success: boolean
|
|
latency_ms?: number
|
|
message?: string
|
|
ip_address?: string
|
|
country?: string
|
|
country_code?: string
|
|
region?: string
|
|
city?: 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
|
|
target.ip_address = result.ip_address
|
|
target.country = result.country
|
|
target.country_code = result.country_code
|
|
target.region = result.region
|
|
target.city = result.city
|
|
} else {
|
|
target.latency_status = 'failed'
|
|
target.latency_ms = undefined
|
|
target.ip_address = undefined
|
|
target.country = undefined
|
|
target.country_code = undefined
|
|
target.region = undefined
|
|
target.city = undefined
|
|
}
|
|
target.latency_message = result.message
|
|
}
|
|
|
|
const summarizeQualityStatus = (result: ProxyQualityCheckResult): Proxy['quality_status'] => {
|
|
if (result.challenge_count > 0) return 'challenge'
|
|
if (result.failed_count > 0) return 'failed'
|
|
if (result.warn_count > 0) return 'warn'
|
|
return 'healthy'
|
|
}
|
|
|
|
const applyQualityResult = (proxyId: number, result: ProxyQualityCheckResult) => {
|
|
const target = proxies.value.find((proxy) => proxy.id === proxyId)
|
|
if (!target) return
|
|
target.quality_status = summarizeQualityStatus(result)
|
|
target.quality_score = result.score
|
|
target.quality_grade = result.grade
|
|
target.quality_summary = result.summary
|
|
target.quality_checked = result.checked_at
|
|
}
|
|
|
|
const formatLocation = (proxy: Proxy) => {
|
|
const parts = [proxy.country, proxy.city].filter(Boolean) as string[]
|
|
return parts.join(' · ')
|
|
}
|
|
|
|
const flagUrl = (code: string) =>
|
|
`https://unpkg.com/flag-icons/flags/4x3/${code.toLowerCase()}.svg`
|
|
|
|
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 startQualityCheckingProxy = (proxyId: number) => {
|
|
qualityCheckingProxyIds.value = new Set([...qualityCheckingProxyIds.value, proxyId])
|
|
}
|
|
|
|
const stopQualityCheckingProxy = (proxyId: number) => {
|
|
const next = new Set(qualityCheckingProxyIds.value)
|
|
next.delete(proxyId)
|
|
qualityCheckingProxyIds.value = next
|
|
}
|
|
|
|
const runProxyTest = async (proxyId: number, notify: boolean) => {
|
|
startTestingProxy(proxyId)
|
|
try {
|
|
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) {
|
|
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 {
|
|
stopTestingProxy(proxyId)
|
|
}
|
|
}
|
|
|
|
const handleTestConnection = async (proxy: Proxy) => {
|
|
await runProxyTest(proxy.id, true)
|
|
}
|
|
|
|
const handleQualityCheck = async (proxy: Proxy) => {
|
|
startQualityCheckingProxy(proxy.id)
|
|
try {
|
|
const result = await adminAPI.proxies.checkProxyQuality(proxy.id)
|
|
qualityReportProxy.value = proxy
|
|
qualityReport.value = result
|
|
showQualityReportDialog.value = true
|
|
|
|
const baseStep = result.items.find((item) => item.target === 'base_connectivity')
|
|
if (baseStep && baseStep.status === 'pass') {
|
|
applyLatencyResult(proxy.id, {
|
|
success: true,
|
|
latency_ms: result.base_latency_ms,
|
|
message: result.summary,
|
|
ip_address: result.exit_ip,
|
|
country: result.country,
|
|
country_code: result.country_code
|
|
})
|
|
}
|
|
applyQualityResult(proxy.id, result)
|
|
|
|
appStore.showSuccess(
|
|
t('admin.proxies.qualityCheckDone', { score: result.score, grade: result.grade })
|
|
)
|
|
} catch (error: any) {
|
|
const message = error.response?.data?.detail || t('admin.proxies.qualityCheckFailed')
|
|
appStore.showError(message)
|
|
console.error('Error checking proxy quality:', error)
|
|
} finally {
|
|
stopQualityCheckingProxy(proxy.id)
|
|
}
|
|
}
|
|
|
|
const runBatchProxyQualityChecks = async (ids: number[]) => {
|
|
if (ids.length === 0) return { total: 0, healthy: 0, warn: 0, challenge: 0, failed: 0 }
|
|
|
|
const concurrency = 3
|
|
let index = 0
|
|
let healthy = 0
|
|
let warn = 0
|
|
let challenge = 0
|
|
let failed = 0
|
|
|
|
const worker = async () => {
|
|
while (index < ids.length) {
|
|
const current = ids[index]
|
|
index++
|
|
startQualityCheckingProxy(current)
|
|
try {
|
|
const result = await adminAPI.proxies.checkProxyQuality(current)
|
|
const target = proxies.value.find((proxy) => proxy.id === current)
|
|
if (target) {
|
|
const baseStep = result.items.find((item) => item.target === 'base_connectivity')
|
|
if (baseStep && baseStep.status === 'pass') {
|
|
applyLatencyResult(current, {
|
|
success: true,
|
|
latency_ms: result.base_latency_ms,
|
|
message: result.summary,
|
|
ip_address: result.exit_ip,
|
|
country: result.country,
|
|
country_code: result.country_code
|
|
})
|
|
}
|
|
}
|
|
applyQualityResult(current, result)
|
|
if (result.challenge_count > 0) {
|
|
challenge++
|
|
} else if (result.failed_count > 0) {
|
|
failed++
|
|
} else if (result.warn_count > 0) {
|
|
warn++
|
|
} else {
|
|
healthy++
|
|
}
|
|
} catch {
|
|
failed++
|
|
} finally {
|
|
stopQualityCheckingProxy(current)
|
|
}
|
|
}
|
|
}
|
|
|
|
const workers = Array.from({ length: Math.min(concurrency, ids.length) }, () => worker())
|
|
await Promise.all(workers)
|
|
return {
|
|
total: ids.length,
|
|
healthy,
|
|
warn,
|
|
challenge,
|
|
failed
|
|
}
|
|
}
|
|
|
|
const closeQualityReportDialog = () => {
|
|
showQualityReportDialog.value = false
|
|
qualityReportProxy.value = null
|
|
qualityReport.value = null
|
|
}
|
|
|
|
const qualityStatusClass = (status: string) => {
|
|
if (status === 'pass') return 'badge-success'
|
|
if (status === 'warn') return 'badge-warning'
|
|
if (status === 'challenge') return 'badge-danger'
|
|
return 'badge-danger'
|
|
}
|
|
|
|
const qualityStatusLabel = (status: string) => {
|
|
if (status === 'pass') return t('admin.proxies.qualityStatusPass')
|
|
if (status === 'warn') return t('admin.proxies.qualityStatusWarn')
|
|
if (status === 'challenge') return t('admin.proxies.qualityStatusChallenge')
|
|
return t('admin.proxies.qualityStatusFail')
|
|
}
|
|
|
|
const qualityOverallClass = (status?: string) => {
|
|
if (status === 'healthy') return 'badge-success'
|
|
if (status === 'warn') return 'badge-warning'
|
|
if (status === 'challenge') return 'badge-danger'
|
|
return 'badge-danger'
|
|
}
|
|
|
|
const qualityOverallLabel = (status?: string) => {
|
|
if (status === 'healthy') return t('admin.proxies.qualityStatusHealthy')
|
|
if (status === 'warn') return t('admin.proxies.qualityStatusWarn')
|
|
if (status === 'challenge') return t('admin.proxies.qualityStatusChallenge')
|
|
return t('admin.proxies.qualityStatusFail')
|
|
}
|
|
|
|
const qualityTargetLabel = (target: string) => {
|
|
switch (target) {
|
|
case 'base_connectivity':
|
|
return t('admin.proxies.qualityTargetBase')
|
|
case 'openai':
|
|
return 'OpenAI'
|
|
case 'anthropic':
|
|
return 'Anthropic'
|
|
case 'gemini':
|
|
return 'Gemini'
|
|
case 'sora':
|
|
return 'Sora'
|
|
default:
|
|
return target
|
|
}
|
|
}
|
|
|
|
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 handleBatchQualityCheck = async () => {
|
|
if (batchQualityChecking.value) return
|
|
|
|
batchQualityChecking.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.batchQualityEmpty'))
|
|
return
|
|
}
|
|
|
|
const summary = await runBatchProxyQualityChecks(ids)
|
|
appStore.showSuccess(
|
|
t('admin.proxies.batchQualityDone', {
|
|
count: summary.total,
|
|
healthy: summary.healthy,
|
|
warn: summary.warn,
|
|
challenge: summary.challenge,
|
|
failed: summary.failed
|
|
})
|
|
)
|
|
loadProxies()
|
|
} catch (error: any) {
|
|
appStore.showError(error.response?.data?.detail || t('admin.proxies.batchQualityFailed'))
|
|
console.error('Error batch checking quality:', error)
|
|
} finally {
|
|
batchQualityChecking.value = false
|
|
}
|
|
}
|
|
|
|
const formatExportTimestamp = () => {
|
|
const now = new Date()
|
|
const pad2 = (value: number) => String(value).padStart(2, '0')
|
|
return `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}`
|
|
}
|
|
|
|
const handleExportData = async () => {
|
|
if (exportingData.value) return
|
|
exportingData.value = true
|
|
try {
|
|
const dataPayload = await adminAPI.proxies.exportData(
|
|
selectedCount.value > 0
|
|
? { ids: Array.from(selectedProxyIds.value) }
|
|
: {
|
|
filters: {
|
|
protocol: filters.protocol || undefined,
|
|
status: (filters.status || undefined) as 'active' | 'inactive' | undefined,
|
|
search: searchQuery.value || undefined
|
|
}
|
|
}
|
|
)
|
|
const timestamp = formatExportTimestamp()
|
|
const filename = `sub2api-proxy-${timestamp}.json`
|
|
const blob = new Blob([JSON.stringify(dataPayload, null, 2)], { type: 'application/json' })
|
|
const url = URL.createObjectURL(blob)
|
|
const link = document.createElement('a')
|
|
link.href = url
|
|
link.download = filename
|
|
link.click()
|
|
URL.revokeObjectURL(url)
|
|
appStore.showSuccess(t('admin.proxies.dataExported'))
|
|
} catch (error: any) {
|
|
appStore.showError(error?.message || t('admin.proxies.dataExportFailed'))
|
|
} finally {
|
|
exportingData.value = false
|
|
showExportDataDialog.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
|
|
|
|
try {
|
|
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) {
|
|
appStore.showError(error.response?.data?.detail || t('admin.proxies.failedToDelete'))
|
|
console.error('Error deleting proxy:', error)
|
|
}
|
|
}
|
|
|
|
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 = []
|
|
}
|
|
|
|
// ── Proxy URL copy ──
|
|
function buildAuthPart(row: any): string {
|
|
const user = row.username ? encodeURIComponent(row.username) : ''
|
|
const pass = row.password ? encodeURIComponent(row.password) : ''
|
|
if (user && pass) return `${user}:${pass}@`
|
|
if (user) return `${user}@`
|
|
if (pass) return `:${pass}@`
|
|
return ''
|
|
}
|
|
|
|
function buildProxyUrl(row: any): string {
|
|
return `${row.protocol}://${buildAuthPart(row)}${row.host}:${row.port}`
|
|
}
|
|
|
|
function getCopyFormats(row: any) {
|
|
const hasAuth = row.username || row.password
|
|
const fullUrl = buildProxyUrl(row)
|
|
const formats = [
|
|
{ label: fullUrl, value: fullUrl },
|
|
]
|
|
if (hasAuth) {
|
|
const withoutProtocol = fullUrl.replace(/^[^:]+:\/\//, '')
|
|
formats.push({ label: withoutProtocol, value: withoutProtocol })
|
|
}
|
|
formats.push({ label: `${row.host}:${row.port}`, value: `${row.host}:${row.port}` })
|
|
return formats
|
|
}
|
|
|
|
function copyProxyUrl(row: any) {
|
|
copyToClipboard(buildProxyUrl(row), t('admin.proxies.urlCopied'))
|
|
copyMenuProxyId.value = null
|
|
}
|
|
|
|
function toggleCopyMenu(id: number) {
|
|
copyMenuProxyId.value = copyMenuProxyId.value === id ? null : id
|
|
}
|
|
|
|
function copyFormat(value: string) {
|
|
copyToClipboard(value, t('admin.proxies.urlCopied'))
|
|
copyMenuProxyId.value = null
|
|
}
|
|
|
|
function closeCopyMenu() {
|
|
copyMenuProxyId.value = null
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadProxies()
|
|
document.addEventListener('click', closeCopyMenu)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
clearTimeout(searchTimeout)
|
|
abortController?.abort()
|
|
document.removeEventListener('click', closeCopyMenu)
|
|
})
|
|
</script>
|