diff --git a/frontend/src/__tests__/integration/data-import.spec.ts b/frontend/src/__tests__/integration/data-import.spec.ts index bc9de148bd..5be8852c7f 100644 --- a/frontend/src/__tests__/integration/data-import.spec.ts +++ b/frontend/src/__tests__/integration/data-import.spec.ts @@ -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: '
' } + } + } + }) + + 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') + }) }) diff --git a/frontend/src/components/admin/account/ImportDataModal.vue b/frontend/src/components/admin/account/ImportDataModal.vue index 6c120be39a..7ede8241a8 100644 --- a/frontend/src/components/admin/account/ImportDataModal.vue +++ b/frontend/src/components/admin/account/ImportDataModal.vue @@ -19,13 +19,23 @@
-
- {{ fileName || t('admin.accounts.dataImportSelectFile') }} +
+ {{ selectedFilesLabel || t('admin.accounts.dataImportSelectFile') }} +
+
+ JSON (.json) + ยท {{ fileListTitle }}
-
JSON (.json)
@@ -108,11 +119,18 @@ const { t } = useI18n() const appStore = useAppStore() const importing = ref(false) -const file = ref(null) +const files = ref([]) +const dragActive = ref(false) +const dragDepth = ref(0) const result = ref(null) const fileInput = ref(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 => { if (typeof sourceFile.text === 'function') { return sourceFile.text() @@ -161,16 +224,36 @@ const readFileAsText = async (sourceFile: File): Promise => { }) } +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,