feat: add proxy import flow

This commit is contained in:
LLLLLLiulei
2026-02-05 18:23:49 +08:00
parent b4bd46d067
commit ce9a247a9d
13 changed files with 580 additions and 10 deletions

View File

@@ -0,0 +1,70 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import ImportDataModal from '@/components/admin/proxy/ImportDataModal.vue'
const showError = vi.fn()
const showSuccess = vi.fn()
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError,
showSuccess
})
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
proxies: {
importData: vi.fn()
}
}
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
describe('Proxy ImportDataModal', () => {
beforeEach(() => {
showError.mockReset()
showSuccess.mockReset()
})
it('未选择文件时提示错误', async () => {
const wrapper = mount(ImportDataModal, {
props: { show: true },
global: {
stubs: {
BaseDialog: { template: '<div><slot /><slot name="footer" /></div>' }
}
}
})
await wrapper.find('form').trigger('submit')
expect(showError).toHaveBeenCalledWith('admin.proxies.dataImportSelectFile')
})
it('无效 JSON 时提示解析失败', async () => {
const wrapper = mount(ImportDataModal, {
props: { show: true },
global: {
stubs: {
BaseDialog: { template: '<div><slot /><slot name="footer" /></div>' }
}
}
})
const input = wrapper.find('input[type="file"]')
const file = new File(['invalid json'], 'data.json', { type: 'application/json' })
Object.defineProperty(input.element, 'files', {
value: [file]
})
await input.trigger('change')
await wrapper.find('form').trigger('submit')
expect(showError).toHaveBeenCalledWith('admin.proxies.dataImportParseFailed')
})
})

View File

@@ -10,7 +10,8 @@ import type {
CreateProxyRequest,
UpdateProxyRequest,
PaginatedResponse,
AdminDataPayload
AdminDataPayload,
AdminDataImportResult
} from '@/types'
/**
@@ -220,6 +221,13 @@ export async function exportData(filters?: {
return data
}
export async function importData(payload: {
data: AdminDataPayload
}): Promise<AdminDataImportResult> {
const { data } = await apiClient.post<AdminDataImportResult>('/admin/proxies/data', payload)
return data
}
export const proxiesAPI = {
list,
getAll,
@@ -234,7 +242,8 @@ export const proxiesAPI = {
getProxyAccounts,
batchCreate,
batchDelete,
exportData
exportData,
importData
}
export default proxiesAPI

View File

@@ -18,15 +18,26 @@
<div>
<label class="input-label">{{ t('admin.accounts.dataImportFile') }}</label>
<div
class="flex items-center justify-between gap-3 rounded-lg border border-dashed border-gray-300 bg-gray-50 px-4 py-3 dark:border-dark-600 dark:bg-dark-800"
>
<div class="min-w-0">
<div class="truncate text-sm text-gray-700 dark:text-dark-200">
{{ fileName || t('admin.accounts.dataImportSelectFile') }}
</div>
<div class="text-xs text-gray-500 dark:text-dark-400">JSON (.json)</div>
</div>
<button type="button" class="btn btn-secondary shrink-0" @click="openFilePicker">
{{ t('common.chooseFile') }}
</button>
</div>
<input
ref="fileInput"
type="file"
class="input"
class="hidden"
accept="application/json,.json"
@change="handleFileChange"
/>
<p v-if="fileName" class="mt-2 text-xs text-gray-500 dark:text-dark-400">
{{ fileName }}
</p>
</div>
<div
@@ -100,6 +111,7 @@ const importing = ref(false)
const file = ref<File | null>(null)
const result = ref<AdminDataImportResult | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const fileName = computed(() => file.value?.name || '')
const errorItems = computed(() => result.value?.errors || [])
@@ -110,10 +122,17 @@ watch(
if (open) {
file.value = null
result.value = null
if (fileInput.value) {
fileInput.value.value = ''
}
}
}
)
const openFilePicker = () => {
fileInput.value?.click()
}
const handleFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
file.value = target.files?.[0] || null

View File

@@ -0,0 +1,183 @@
<template>
<BaseDialog
:show="show"
:title="t('admin.proxies.dataImportTitle')"
width="normal"
close-on-click-outside
@close="handleClose"
>
<form id="import-proxy-data-form" class="space-y-4" @submit.prevent="handleImport">
<div class="text-sm text-gray-600 dark:text-dark-300">
{{ t('admin.proxies.dataImportHint') }}
</div>
<div
class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-600 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-400"
>
{{ t('admin.proxies.dataImportWarning') }}
</div>
<div>
<label class="input-label">{{ t('admin.proxies.dataImportFile') }}</label>
<div
class="flex items-center justify-between gap-3 rounded-lg border border-dashed border-gray-300 bg-gray-50 px-4 py-3 dark:border-dark-600 dark:bg-dark-800"
>
<div class="min-w-0">
<div class="truncate text-sm text-gray-700 dark:text-dark-200">
{{ fileName || t('admin.proxies.dataImportSelectFile') }}
</div>
<div class="text-xs text-gray-500 dark:text-dark-400">JSON (.json)</div>
</div>
<button type="button" class="btn btn-secondary shrink-0" @click="openFilePicker">
{{ t('common.chooseFile') }}
</button>
</div>
<input
ref="fileInput"
type="file"
class="hidden"
accept="application/json,.json"
@change="handleFileChange"
/>
</div>
<div
v-if="result"
class="space-y-2 rounded-xl border border-gray-200 p-4 dark:border-dark-700"
>
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.proxies.dataImportResult') }}
</div>
<div class="text-sm text-gray-700 dark:text-dark-300">
{{ t('admin.proxies.dataImportResultSummary', result) }}
</div>
<div v-if="errorItems.length" class="mt-2">
<div class="text-sm font-medium text-red-600 dark:text-red-400">
{{ t('admin.proxies.dataImportErrors') }}
</div>
<div
class="mt-2 max-h-48 overflow-auto rounded-lg bg-gray-50 p-3 font-mono text-xs dark:bg-dark-800"
>
<div v-for="(item, idx) in errorItems" :key="idx" class="whitespace-pre-wrap">
{{ item.kind }} {{ item.name || item.proxy_key || '-' }} {{ item.message }}
</div>
</div>
</div>
</div>
</form>
<template #footer>
<div class="flex justify-end gap-3">
<button class="btn btn-secondary" type="button" :disabled="importing" @click="handleClose">
{{ t('common.cancel') }}
</button>
<button
class="btn btn-primary"
type="submit"
form="import-proxy-data-form"
:disabled="importing"
>
{{ importing ? t('admin.proxies.dataImporting') : t('admin.proxies.dataImportButton') }}
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import { adminAPI } from '@/api/admin'
import { useAppStore } from '@/stores/app'
import type { AdminDataImportResult } from '@/types'
interface Props {
show: boolean
}
interface Emits {
(e: 'close'): void
(e: 'imported'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { t } = useI18n()
const appStore = useAppStore()
const importing = ref(false)
const file = ref<File | null>(null)
const result = ref<AdminDataImportResult | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const fileName = computed(() => file.value?.name || '')
const errorItems = computed(() => result.value?.errors || [])
watch(
() => props.show,
(open) => {
if (open) {
file.value = null
result.value = null
if (fileInput.value) {
fileInput.value.value = ''
}
}
}
)
const openFilePicker = () => {
fileInput.value?.click()
}
const handleFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
file.value = target.files?.[0] || null
}
const handleClose = () => {
if (importing.value) return
emit('close')
}
const handleImport = async () => {
if (!file.value) {
appStore.showError(t('admin.proxies.dataImportSelectFile'))
return
}
importing.value = true
try {
const text = await file.value.text()
const dataPayload = JSON.parse(text)
const res = await adminAPI.proxies.importData({ data: dataPayload })
result.value = res
const msgParams: Record<string, unknown> = {
proxy_created: res.proxy_created,
proxy_reused: res.proxy_reused,
proxy_failed: res.proxy_failed
}
if (res.proxy_failed > 0) {
appStore.showError(t('admin.proxies.dataImportCompletedWithErrors', msgParams))
} else {
appStore.showSuccess(t('admin.proxies.dataImportSuccess', msgParams))
emit('imported')
}
} catch (error: any) {
if (error instanceof SyntaxError) {
appStore.showError(t('admin.proxies.dataImportParseFailed'))
} else {
appStore.showError(error?.message || t('admin.proxies.dataImportFailed'))
}
} finally {
importing.value = false
}
}
</script>

View File

@@ -165,6 +165,7 @@ export default {
selectedCount: '({count} selected)',
refresh: 'Refresh',
settings: 'Settings',
chooseFile: 'Choose File',
notAvailable: 'N/A',
now: 'Now',
unknown: 'Unknown',
@@ -1190,7 +1191,7 @@ export default {
syncFromCrs: 'Sync from CRS',
dataExport: 'Export',
dataExportSelected: 'Export Selected',
dataExportIncludeProxies: 'Include proxies (unchecked = no proxy linkage on import)',
dataExportIncludeProxies: 'Include proxies linked to the exported accounts',
dataImport: 'Import',
dataExportConfirmMessage: 'The exported data contains sensitive account and proxy information. Store it securely.',
dataExportConfirm: 'Confirm Export',
@@ -1198,7 +1199,7 @@ export default {
dataExportFailed: 'Failed to export data',
dataImportTitle: 'Import Data',
dataImportHint: 'Upload the exported JSON file to import accounts and proxies.',
dataImportWarning: 'Import will create new accounts/proxies; groups must be bound manually. Ensure no conflicts in the target instance.',
dataImportWarning: 'Import will create new accounts/proxies; groups must be bound manually. Ensure existing data does not conflict.',
dataImportFile: 'Data file',
dataImportButton: 'Start Import',
dataImporting: 'Importing...',
@@ -1901,6 +1902,21 @@ export default {
createProxy: 'Create Proxy',
editProxy: 'Edit Proxy',
deleteProxy: 'Delete Proxy',
dataImport: 'Import',
dataImportTitle: 'Import Proxies',
dataImportHint: 'Upload the exported proxy JSON file to import proxies in bulk.',
dataImportWarning: 'Import will create or reuse proxies, keep their status, and trigger latency checks after completion.',
dataImportFile: 'Data File',
dataImportButton: 'Start Import',
dataImporting: 'Importing...',
dataImportSelectFile: 'Please select a data file',
dataImportParseFailed: 'Failed to parse data',
dataImportFailed: 'Failed to import data',
dataImportResult: 'Import Result',
dataImportResultSummary: 'Created {proxy_created}, reused {proxy_reused}, failed {proxy_failed}',
dataImportErrors: 'Failure Details',
dataImportSuccess: 'Import completed: created {proxy_created}, reused {proxy_reused}',
dataImportCompletedWithErrors: 'Import completed with errors: failed {proxy_failed}',
dataExport: 'Export',
dataExportConfirmMessage: 'The exported data contains sensitive proxy information. Store it securely.',
dataExportConfirm: 'Confirm Export',

View File

@@ -162,6 +162,7 @@ export default {
selectedCount: '(已选 {count} 个)',
refresh: '刷新',
settings: '设置',
chooseFile: '选择文件',
notAvailable: '不可用',
now: '现在',
unknown: '未知',
@@ -1275,7 +1276,7 @@ export default {
syncFromCrs: '从 CRS 同步',
dataExport: '导出',
dataExportSelected: '导出选中',
dataExportIncludeProxies: '导出代理(取消后导入时不关联代理)',
dataExportIncludeProxies: '导出代理(导出账号关联代理)',
dataImport: '导入',
dataExportConfirmMessage: '导出的数据包含账号与代理的敏感信息,请妥善保存。',
dataExportConfirm: '确认导出',
@@ -1283,7 +1284,7 @@ export default {
dataExportFailed: '数据导出失败',
dataImportTitle: '导入数据',
dataImportHint: '上传导出的 JSON 文件以批量导入账号与代理。',
dataImportWarning: '导入将创建新账号与代理,分组需手工绑定;请确认目标实例已有数据不会冲突。',
dataImportWarning: '导入将创建新账号与代理,分组需手工绑定;请确认已有数据不会冲突。',
dataImportFile: '数据文件',
dataImportButton: '开始导入',
dataImporting: '导入中...',
@@ -2010,6 +2011,21 @@ export default {
deleteProxy: '删除代理',
deleteConfirmMessage: "确定要删除代理 '{name}' 吗?",
testProxy: '测试代理',
dataImport: '导入',
dataImportTitle: '导入代理',
dataImportHint: '上传代理导出的 JSON 文件以批量导入代理。',
dataImportWarning: '导入将创建或复用代理,保留状态并在完成后自动触发延迟检测。',
dataImportFile: '数据文件',
dataImportButton: '开始导入',
dataImporting: '导入中...',
dataImportSelectFile: '请选择数据文件',
dataImportParseFailed: '数据解析失败',
dataImportFailed: '数据导入失败',
dataImportResult: '导入结果',
dataImportResultSummary: '创建 {proxy_created},复用 {proxy_reused},失败 {proxy_failed}',
dataImportErrors: '失败详情',
dataImportSuccess: '导入完成:创建 {proxy_created},复用 {proxy_reused}',
dataImportCompletedWithErrors: '导入完成但有错误:失败 {proxy_failed}',
dataExport: '导出',
dataExportConfirmMessage: '导出的数据包含代理的敏感信息,请妥善保存。',
dataExportConfirm: '确认导出',

View File

@@ -118,6 +118,15 @@
default-sort-order="asc"
:sort-storage-key="ACCOUNT_SORT_STORAGE_KEY"
>
<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" :checked="selIds.includes(row.id)" @change="toggleSel(row.id)" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
</template>
@@ -551,6 +560,21 @@ const openMenu = (a: Account, e: MouseEvent) => {
menu.show = true
}
const toggleSel = (id: number) => { const i = selIds.value.indexOf(id); if(i === -1) selIds.value.push(id); else selIds.value.splice(i, 1) }
const allVisibleSelected = computed(() => {
if (accounts.value.length === 0) return false
return accounts.value.every(account => selIds.value.includes(account.id))
})
const toggleSelectAllVisible = (event: Event) => {
const target = event.target as HTMLInputElement
if (target.checked) {
const next = new Set(selIds.value)
accounts.value.forEach(account => next.add(account.id))
selIds.value = Array.from(next)
return
}
const visibleIds = new Set(accounts.value.map(account => account.id))
selIds.value = selIds.value.filter(id => !visibleIds.has(id))
}
const selectPage = () => { selIds.value = [...new Set([...selIds.value, ...accounts.value.map(a => a.id)])] }
const handleBulkDelete = async () => { if(!confirm(t('common.confirm'))) return; try { await Promise.all(selIds.value.map(id => adminAPI.accounts.delete(id))); selIds.value = []; reload() } catch (error) { console.error('Failed to bulk delete accounts:', error) } }
const updateSchedulableInList = (accountIds: number[], schedulable: boolean) => {

View File

@@ -69,6 +69,9 @@
<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">
{{ t('admin.proxies.dataExport') }}
</button>
@@ -619,6 +622,12 @@
@cancel="showExportDataDialog = false"
/>
<ImportDataModal
:show="showImportData"
@close="showImportData = false"
@imported="handleDataImported"
/>
<!-- Proxy Accounts Dialog -->
<BaseDialog
:show="showAccountsModal"
@@ -680,6 +689,7 @@ 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'
@@ -743,6 +753,7 @@ const pagination = reactive({
const showCreateModal = ref(false)
const showEditModal = ref(false)
const showImportData = ref(false)
const showDeleteDialog = ref(false)
const showBatchDeleteDialog = ref(false)
const showExportDataDialog = ref(false)
@@ -902,6 +913,11 @@ const closeCreateModal = () => {
batchParseResult.proxies = []
}
const handleDataImported = () => {
showImportData.value = false
loadProxies()
}
// Parse proxy URL: protocol://user:pass@host:port or protocol://host:port
const parseProxyUrl = (
line: string