mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
feat(frontend): 支持账号数据拖拽和批量导入
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import ImportDataModal from '@/components/admin/account/ImportDataModal.vue'
|
||||
|
||||
const showError = vi.fn()
|
||||
@@ -71,4 +71,55 @@ describe('ImportDataModal', () => {
|
||||
|
||||
expect(showError).toHaveBeenCalledWith('admin.accounts.dataImportParseFailed')
|
||||
})
|
||||
|
||||
it('merges multiple selected JSON files before importing', async () => {
|
||||
const { adminAPI } = await import('@/api/admin')
|
||||
vi.mocked(adminAPI.accounts.importData).mockResolvedValue({
|
||||
proxy_created: 0,
|
||||
proxy_reused: 0,
|
||||
proxy_failed: 0,
|
||||
account_created: 2,
|
||||
account_failed: 0
|
||||
})
|
||||
|
||||
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 first = new File([
|
||||
JSON.stringify({ exported_at: '2026-07-05T00:00:00Z', proxies: [], accounts: [{ name: 'a' }] })
|
||||
], 'first.json', { type: 'application/json' })
|
||||
const second = new File([
|
||||
JSON.stringify({ exported_at: '2026-07-05T00:00:01Z', proxies: [{ proxy_key: 'p' }], accounts: [{ name: 'b' }] })
|
||||
], 'second.json', { type: 'application/json' })
|
||||
Object.defineProperty(first, 'text', {
|
||||
value: () => Promise.resolve(JSON.stringify({ exported_at: '2026-07-05T00:00:00Z', proxies: [], accounts: [{ name: 'a' }] }))
|
||||
})
|
||||
Object.defineProperty(second, 'text', {
|
||||
value: () => Promise.resolve(JSON.stringify({ exported_at: '2026-07-05T00:00:01Z', proxies: [{ proxy_key: 'p' }], accounts: [{ name: 'b' }] }))
|
||||
})
|
||||
|
||||
Object.defineProperty(input.element, 'files', {
|
||||
value: [first, second]
|
||||
})
|
||||
|
||||
await input.trigger('change')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(adminAPI.accounts.importData).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
proxies: [{ proxy_key: 'p' }],
|
||||
accounts: [{ name: 'a' }, { name: 'b' }]
|
||||
}),
|
||||
skip_default_group_bind: true
|
||||
})
|
||||
expect(showSuccess).toHaveBeenCalledWith('admin.accounts.dataImportSuccess')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,13 +19,23 @@
|
||||
<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"
|
||||
class="flex items-center justify-between gap-3 rounded-lg border border-dashed px-4 py-3 transition-colors"
|
||||
:class="dragActive
|
||||
? 'border-primary-400 bg-primary-50/70 dark:border-primary-500 dark:bg-primary-900/20'
|
||||
: 'border-gray-300 bg-gray-50 dark:border-dark-600 dark:bg-dark-800'"
|
||||
@dragenter.prevent="handleDragEnter"
|
||||
@dragover.prevent="handleDragOver"
|
||||
@dragleave.prevent="handleDragLeave"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm text-gray-700 dark:text-dark-200">
|
||||
{{ fileName || t('admin.accounts.dataImportSelectFile') }}
|
||||
<div class="truncate text-sm text-gray-700 dark:text-dark-200" :title="fileListTitle">
|
||||
{{ selectedFilesLabel || t('admin.accounts.dataImportSelectFile') }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-dark-400">
|
||||
JSON (.json)
|
||||
<span v-if="files.length > 1"> · {{ fileListTitle }}</span>
|
||||
</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') }}
|
||||
@@ -36,6 +46,7 @@
|
||||
type="file"
|
||||
class="hidden"
|
||||
accept="application/json,.json"
|
||||
multiple
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
</div>
|
||||
@@ -108,11 +119,18 @@ const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const importing = ref(false)
|
||||
const file = ref<File | null>(null)
|
||||
const files = ref<File[]>([])
|
||||
const dragActive = ref(false)
|
||||
const dragDepth = ref(0)
|
||||
const result = ref<AdminDataImportResult | null>(null)
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const fileName = computed(() => file.value?.name || '')
|
||||
const selectedFilesLabel = computed(() => {
|
||||
if (files.value.length === 0) return ''
|
||||
if (files.value.length === 1) return files.value[0]?.name || ''
|
||||
return t('admin.accounts.selectedCount', { count: files.value.length })
|
||||
})
|
||||
const fileListTitle = computed(() => files.value.map((item) => item.name).join(', '))
|
||||
|
||||
const errorItems = computed(() => result.value?.errors || [])
|
||||
|
||||
@@ -120,7 +138,9 @@ watch(
|
||||
() => props.show,
|
||||
(open) => {
|
||||
if (open) {
|
||||
file.value = null
|
||||
files.value = []
|
||||
dragActive.value = false
|
||||
dragDepth.value = 0
|
||||
result.value = null
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = ''
|
||||
@@ -135,7 +155,7 @@ const openFilePicker = () => {
|
||||
|
||||
const handleFileChange = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
file.value = target.files?.[0] || null
|
||||
setSelectedFiles(target.files)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -143,6 +163,49 @@ const handleClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const isJsonFile = (sourceFile: File) => {
|
||||
const name = sourceFile.name.toLowerCase()
|
||||
return name.endsWith('.json') || sourceFile.type === 'application/json'
|
||||
}
|
||||
|
||||
const setSelectedFiles = (sourceFiles: FileList | File[] | null | undefined) => {
|
||||
if (importing.value) return
|
||||
const picked = Array.from(sourceFiles || []).filter(isJsonFile)
|
||||
if (!picked.length) {
|
||||
files.value = []
|
||||
appStore.showError(t('admin.accounts.dataImportSelectFile'))
|
||||
return
|
||||
}
|
||||
files.value = picked
|
||||
result.value = null
|
||||
}
|
||||
|
||||
const handleDragEnter = () => {
|
||||
if (importing.value) return
|
||||
dragDepth.value += 1
|
||||
dragActive.value = true
|
||||
}
|
||||
|
||||
const handleDragOver = () => {
|
||||
if (importing.value) return
|
||||
dragActive.value = true
|
||||
}
|
||||
|
||||
const handleDragLeave = () => {
|
||||
if (importing.value) return
|
||||
dragDepth.value = Math.max(0, dragDepth.value - 1)
|
||||
if (dragDepth.value === 0) {
|
||||
dragActive.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
if (importing.value) return
|
||||
dragDepth.value = 0
|
||||
dragActive.value = false
|
||||
setSelectedFiles(event.dataTransfer?.files)
|
||||
}
|
||||
|
||||
const readFileAsText = async (sourceFile: File): Promise<string> => {
|
||||
if (typeof sourceFile.text === 'function') {
|
||||
return sourceFile.text()
|
||||
@@ -161,16 +224,36 @@ const readFileAsText = async (sourceFile: File): Promise<string> => {
|
||||
})
|
||||
}
|
||||
|
||||
const mergeDataPayloads = (payloads: any[]) => {
|
||||
if (payloads.length === 1) return payloads[0]
|
||||
|
||||
return {
|
||||
type: payloads.find((item) => typeof item?.type === 'string')?.type,
|
||||
version: payloads.find((item) => typeof item?.version === 'number')?.version,
|
||||
exported_at: new Date().toISOString(),
|
||||
proxies: payloads.flatMap((item) => Array.isArray(item?.proxies) ? item.proxies : []),
|
||||
accounts: payloads.flatMap((item) => Array.isArray(item?.accounts) ? item.accounts : []),
|
||||
skipped_shadows: payloads.reduce((sum, item) => {
|
||||
const count = Number(item?.skipped_shadows || 0)
|
||||
return Number.isFinite(count) ? sum + count : sum
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file.value) {
|
||||
if (files.value.length === 0) {
|
||||
appStore.showError(t('admin.accounts.dataImportSelectFile'))
|
||||
return
|
||||
}
|
||||
|
||||
importing.value = true
|
||||
try {
|
||||
const text = await readFileAsText(file.value)
|
||||
const dataPayload = JSON.parse(text)
|
||||
const dataPayloads = []
|
||||
for (const sourceFile of files.value) {
|
||||
const text = await readFileAsText(sourceFile)
|
||||
dataPayloads.push(JSON.parse(text))
|
||||
}
|
||||
const dataPayload = mergeDataPayloads(dataPayloads)
|
||||
|
||||
const res = await adminAPI.accounts.importData({
|
||||
data: dataPayload,
|
||||
|
||||
Reference in New Issue
Block a user