diff --git a/frontend/src/__tests__/integration/data-import.spec.ts b/frontend/src/__tests__/integration/data-import.spec.ts index bc9de148bd..0a1fcf9372 100644 --- a/frontend/src/__tests__/integration/data-import.spec.ts +++ b/frontend/src/__tests__/integration/data-import.spec.ts @@ -1,6 +1,11 @@ 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' +import { mergeAccountImportPayloads, normalizeAccountImportPayload } from '@/utils/adminDataImport' + +const { importData } = vi.hoisted(() => ({ + importData: vi.fn() +})) const showError = vi.fn() const showSuccess = vi.fn() @@ -15,7 +20,7 @@ vi.mock('@/stores/app', () => ({ vi.mock('@/api/admin', () => ({ adminAPI: { accounts: { - importData: vi.fn() + importData } } })) @@ -30,6 +35,7 @@ describe('ImportDataModal', () => { beforeEach(() => { showError.mockReset() showSuccess.mockReset() + importData.mockReset() }) it('未选择文件时提示错误', async () => { @@ -67,8 +73,190 @@ describe('ImportDataModal', () => { await input.trigger('change') await wrapper.find('form').trigger('submit') - await Promise.resolve() + await flushPromises() expect(showError).toHaveBeenCalledWith('admin.accounts.dataImportParseFailed') }) + + it('支持纯账号数组批量导入', async () => { + importData.mockResolvedValue({ + proxy_created: 0, + proxy_reused: 0, + proxy_failed: 0, + account_created: 2, + account_failed: 0, + errors: [] + }) + + const wrapper = mount(ImportDataModal, { + props: { show: true }, + global: { + stubs: { + BaseDialog: { template: '
' } + } + } + }) + + const input = wrapper.find('input[type="file"]') + const file = new File( + [ + JSON.stringify([ + { + name: 'acc-1', + platform: 'openai', + type: 'oauth', + credentials: { access_token: 'token-1' }, + concurrency: 2, + priority: 10 + }, + { + name: 'acc-2', + platform: 'gemini', + type: 'apikey', + credentials: { api_key: 'token-2' }, + concurrency: 3, + priority: 20 + } + ]) + ], + 'data.json', + { type: 'application/json' } + ) + Object.defineProperty(file, 'text', { + value: () => + Promise.resolve( + JSON.stringify([ + { + name: 'acc-1', + platform: 'openai', + type: 'oauth', + credentials: { access_token: 'token-1' }, + concurrency: 2, + priority: 10 + }, + { + name: 'acc-2', + platform: 'gemini', + type: 'apikey', + credentials: { api_key: 'token-2' }, + concurrency: 3, + priority: 20 + } + ]) + ) + }) + Object.defineProperty(input.element, 'files', { + value: [file] + }) + + await input.trigger('change') + await wrapper.find('form').trigger('submit') + await flushPromises() + + expect(importData).toHaveBeenCalledTimes(1) + expect(importData).toHaveBeenCalledWith({ + data: { + type: 'sub2api-data', + version: 1, + exported_at: expect.any(String), + proxies: [], + accounts: [ + { + name: 'acc-1', + notes: null, + platform: 'openai', + type: 'oauth', + credentials: { access_token: 'token-1' }, + extra: undefined, + proxy_key: null, + concurrency: 2, + priority: 10, + rate_multiplier: null, + expires_at: null, + auto_pause_on_expired: undefined + }, + { + name: 'acc-2', + notes: null, + platform: 'gemini', + type: 'apikey', + credentials: { api_key: 'token-2' }, + extra: undefined, + proxy_key: null, + concurrency: 3, + priority: 20, + rate_multiplier: null, + expires_at: null, + auto_pause_on_expired: undefined + } + ] + }, + skip_default_group_bind: true + }) + expect(showSuccess).toHaveBeenCalledWith('admin.accounts.dataImportSuccess') + }) + + it('支持一次选择多个单独 json 文件', async () => { + importData.mockResolvedValue({ + proxy_created: 0, + proxy_reused: 0, + proxy_failed: 0, + account_created: 2, + account_failed: 0, + errors: [] + }) + + const wrapper = mount(ImportDataModal, { + props: { show: true }, + global: { + stubs: { + BaseDialog: { template: '
' } + } + } + }) + + const payloadA = { + name: 'acc-a', + platform: 'openai', + type: 'oauth', + credentials: { access_token: 'token-a' }, + concurrency: 1, + priority: 10 + } + const payloadB = { + name: 'acc-b', + platform: 'gemini', + type: 'apikey', + credentials: { api_key: 'token-b' }, + concurrency: 2, + priority: 20 + } + + const fileA = new File([JSON.stringify(payloadA)], 'a.json', { type: 'application/json' }) + const fileB = new File([JSON.stringify(payloadB)], 'b.json', { type: 'application/json' }) + Object.defineProperty(fileA, 'text', { value: () => Promise.resolve(JSON.stringify(payloadA)) }) + Object.defineProperty(fileB, 'text', { value: () => Promise.resolve(JSON.stringify(payloadB)) }) + + const input = wrapper.find('input[type="file"]') + Object.defineProperty(input.element, 'files', { + value: [fileA, fileB] + }) + + await input.trigger('change') + await wrapper.find('form').trigger('submit') + await flushPromises() + + const expectedPayload = mergeAccountImportPayloads([ + normalizeAccountImportPayload(payloadA), + normalizeAccountImportPayload(payloadB) + ]) + + expect(importData).toHaveBeenCalledWith({ + data: { + ...expectedPayload, + exported_at: expect.any(String) + }, + skip_default_group_bind: true + }) + }) }) diff --git a/frontend/src/components/admin/account/ImportDataModal.vue b/frontend/src/components/admin/account/ImportDataModal.vue index 6c120be39a..09e0b10bb5 100644 --- a/frontend/src/components/admin/account/ImportDataModal.vue +++ b/frontend/src/components/admin/account/ImportDataModal.vue @@ -36,6 +36,7 @@ type="file" class="hidden" accept="application/json,.json" + multiple @change="handleFileChange" /> @@ -91,6 +92,7 @@ import BaseDialog from '@/components/common/BaseDialog.vue' import { adminAPI } from '@/api/admin' import { useAppStore } from '@/stores/app' import type { AdminDataImportResult } from '@/types' +import { mergeAccountImportPayloads, normalizeAccountImportPayload } from '@/utils/adminDataImport' interface Props { show: boolean @@ -108,11 +110,15 @@ const { t } = useI18n() const appStore = useAppStore() const importing = ref(false) -const file = ref(null) +const files = ref([]) const result = ref(null) const fileInput = ref(null) -const fileName = computed(() => file.value?.name || '') +const fileName = computed(() => { + if (files.value.length === 0) return '' + if (files.value.length === 1) return files.value[0].name + return t('admin.accounts.dataImportSelectedFiles', { count: files.value.length }) +}) const errorItems = computed(() => result.value?.errors || []) @@ -120,7 +126,7 @@ watch( () => props.show, (open) => { if (open) { - file.value = null + files.value = [] result.value = null if (fileInput.value) { fileInput.value.value = '' @@ -135,7 +141,7 @@ const openFilePicker = () => { const handleFileChange = (event: Event) => { const target = event.target as HTMLInputElement - file.value = target.files?.[0] || null + files.value = Array.from(target.files || []) } const handleClose = () => { @@ -162,15 +168,20 @@ const readFileAsText = async (sourceFile: File): Promise => { } 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 payloads = await Promise.all( + files.value.map(async (file) => { + const text = await readFileAsText(file) + return normalizeAccountImportPayload(JSON.parse(text)) + }) + ) + const dataPayload = mergeAccountImportPayloads(payloads) const res = await adminAPI.accounts.importData({ data: dataPayload, diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index ba3703effd..7dfddd05a0 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1851,7 +1851,8 @@ export default { dataExported: 'Data exported successfully', dataExportFailed: 'Failed to export data', dataImportTitle: 'Import Data', - dataImportHint: 'Upload the exported JSON file to import accounts and proxies.', + dataImportHint: 'Select one or more JSON files. Supported formats: exported data files, raw account arrays, and JSON objects with accounts.', + dataImportSelectedFiles: '{count} files selected', dataImportWarning: 'Import will create new accounts/proxies; groups must be bound manually. Ensure existing data does not conflict.', dataImportFile: 'Data file', dataImportButton: 'Start Import', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 52dd6cdb71..7f148ed77f 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -1930,7 +1930,8 @@ export default { dataExported: '数据导出成功', dataExportFailed: '数据导出失败', dataImportTitle: '导入数据', - dataImportHint: '上传导出的 JSON 文件以批量导入账号与代理。', + dataImportHint: '可一次选择一个或多个 JSON 文件;支持导出文件、账号数组 JSON、以及包含 accounts 的 JSON 对象。', + dataImportSelectedFiles: '已选择 {count} 个文件', dataImportWarning: '导入将创建新账号与代理,分组需手工绑定;请确认已有数据不会冲突。', dataImportFile: '数据文件', dataImportButton: '开始导入', diff --git a/frontend/src/utils/__tests__/adminDataImport.spec.ts b/frontend/src/utils/__tests__/adminDataImport.spec.ts new file mode 100644 index 0000000000..cc3db79a5a --- /dev/null +++ b/frontend/src/utils/__tests__/adminDataImport.spec.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest' +import { mergeAccountImportPayloads, normalizeAccountImportPayload } from '@/utils/adminDataImport' + +describe('normalizeAccountImportPayload', () => { + it('兼容纯账号数组批量导入', () => { + const payload = normalizeAccountImportPayload( + [ + { + name: 'acc-1', + platform: 'openai', + type: 'oauth', + credentials: { access_token: 'token-1' }, + concurrency: 2, + priority: 10 + }, + { + name: 'acc-2', + platform: 'gemini', + type: 'apikey', + credentials: { api_key: 'token-2' }, + concurrency: 3, + priority: 20 + } + ], + '2026-04-04T00:00:00.000Z' + ) + + expect(payload.type).toBe('sub2api-data') + expect(payload.version).toBe(1) + expect(payload.exported_at).toBe('2026-04-04T00:00:00.000Z') + expect(payload.proxies).toEqual([]) + expect(payload.accounts).toHaveLength(2) + expect(payload.accounts[0].name).toBe('acc-1') + expect(payload.accounts[1].type).toBe('apikey') + }) + + it('兼容 data 包装格式并提取内联代理', () => { + const payload = normalizeAccountImportPayload( + { + data: { + accounts: [ + { + name: 'acc-1', + platform: 'openai', + type: 'oauth', + credentials: { access_token: 'token-1' }, + proxy: { + name: 'proxy-a', + protocol: 'http', + host: '127.0.0.1', + port: 8080, + username: 'user', + password: 'pass', + status: 'active' + }, + concurrency: 1, + priority: 5 + } + ] + } + }, + '2026-04-04T00:00:00.000Z' + ) + + expect(payload.accounts).toHaveLength(1) + expect(payload.accounts[0].proxy_key).toBe('http|127.0.0.1|8080|user|pass') + expect(payload.proxies).toEqual([ + { + proxy_key: 'http|127.0.0.1|8080|user|pass', + name: 'proxy-a', + protocol: 'http', + host: '127.0.0.1', + port: 8080, + username: 'user', + password: 'pass', + status: 'active' + } + ]) + }) + + it('不支持的格式会报错', () => { + expect(() => normalizeAccountImportPayload('invalid')).toThrow('Unsupported import payload') + }) + + it('支持合并多个导入 payload', () => { + const payloadA = normalizeAccountImportPayload([ + { + name: 'acc-1', + platform: 'openai', + type: 'oauth', + credentials: { access_token: 'token-1' }, + concurrency: 1, + priority: 10 + } + ]) + const payloadB = normalizeAccountImportPayload({ + accounts: [ + { + name: 'acc-2', + platform: 'gemini', + type: 'apikey', + credentials: { api_key: 'token-2' }, + concurrency: 2, + priority: 20 + } + ], + proxies: [ + { + proxy_key: 'http|127.0.0.1|8080||', + name: 'proxy-a', + protocol: 'http', + host: '127.0.0.1', + port: 8080, + status: 'active' + } + ] + }) + + const merged = mergeAccountImportPayloads([payloadA, payloadB], '2026-04-04T00:00:00.000Z') + + expect(merged.exported_at).toBe('2026-04-04T00:00:00.000Z') + expect(merged.accounts).toHaveLength(2) + expect(merged.proxies).toHaveLength(1) + }) +}) diff --git a/frontend/src/utils/adminDataImport.ts b/frontend/src/utils/adminDataImport.ts new file mode 100644 index 0000000000..e7ca3b7bd3 --- /dev/null +++ b/frontend/src/utils/adminDataImport.ts @@ -0,0 +1,174 @@ +import type { AdminDataAccount, AdminDataPayload, AdminDataProxy, ProxyProtocol } from '@/types' + +const DEFAULT_DATA_TYPE = 'sub2api-data' +const DEFAULT_DATA_VERSION = 1 + +type UnknownRecord = Record + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function readString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined +} + +function readNullableString(value: unknown): string | null | undefined { + if (value == null) return null + return typeof value === 'string' ? value : undefined +} + +function readNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +function readBoolean(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined +} + +function readObject(value: unknown): Record | undefined { + return isRecord(value) ? value : undefined +} + +function normalizeStatus(value: unknown): 'active' | 'inactive' { + return String(value ?? 'active').trim().toLowerCase() === 'inactive' ? 'inactive' : 'active' +} + +function buildProxyKey( + protocol: string, + host: string, + port: number, + username?: string | null, + password?: string | null +): string { + return [ + protocol.trim(), + host.trim(), + String(port), + (username ?? '').trim(), + (password ?? '').trim() + ].join('|') +} + +function normalizeProxy(raw: unknown): AdminDataProxy { + const item = readObject(raw) ?? {} + const protocol = readString(item.protocol) ?? '' + const host = readString(item.host) ?? '' + const port = readNumber(item.port) ?? 0 + const username = readNullableString(item.username) + const password = readNullableString(item.password) + const proxyKey = + readString(item.proxy_key) ?? + readString(item.proxyKey) ?? + buildProxyKey(protocol, host, port, username, password) + + return { + proxy_key: proxyKey, + name: readString(item.name) ?? '', + protocol: protocol as ProxyProtocol, + host, + port, + username, + password, + status: normalizeStatus(item.status) + } +} + +function normalizeAccount( + raw: unknown +): { account: AdminDataAccount; inlineProxy?: AdminDataProxy } { + const item = readObject(raw) ?? {} + const inlineProxy = readObject(item.proxy) ? normalizeProxy(item.proxy) : undefined + const proxyKey = + readString(item.proxy_key) ?? + readString(item.proxyKey) ?? + inlineProxy?.proxy_key ?? + null + + return { + account: { + name: readString(item.name) ?? '', + notes: readNullableString(item.notes), + platform: (readString(item.platform) ?? '') as AdminDataAccount['platform'], + type: (readString(item.type) ?? '') as AdminDataAccount['type'], + credentials: readObject(item.credentials) ?? {}, + extra: readObject(item.extra), + proxy_key: proxyKey, + concurrency: readNumber(item.concurrency) ?? 0, + priority: readNumber(item.priority) ?? 0, + rate_multiplier: readNumber(item.rate_multiplier ?? item.rateMultiplier) ?? null, + expires_at: readNumber(item.expires_at ?? item.expiresAt) ?? null, + auto_pause_on_expired: readBoolean(item.auto_pause_on_expired ?? item.autoPauseOnExpired) + }, + inlineProxy + } +} + +function dedupeProxies(items: AdminDataProxy[]): AdminDataProxy[] { + const proxyByKey = new Map() + for (const item of items) { + proxyByKey.set(item.proxy_key, item) + } + return [...proxyByKey.values()] +} + +function normalizePayloadObject( + source: UnknownRecord, + exportedAt: string +): AdminDataPayload { + const proxies = Array.isArray(source.proxies) ? source.proxies.map(normalizeProxy) : [] + const normalizedAccounts = Array.isArray(source.accounts) + ? source.accounts.map(normalizeAccount) + : [] + const inlineProxies = normalizedAccounts + .map((item) => item.inlineProxy) + .filter((item): item is AdminDataProxy => Boolean(item)) + + return { + type: readString(source.type) ?? DEFAULT_DATA_TYPE, + version: readNumber(source.version) ?? DEFAULT_DATA_VERSION, + exported_at: readString(source.exported_at) ?? readString(source.exportedAt) ?? exportedAt, + proxies: dedupeProxies([...proxies, ...inlineProxies]), + accounts: normalizedAccounts.map((item) => item.account) + } +} + +export function normalizeAccountImportPayload( + input: unknown, + exportedAt: string = new Date().toISOString() +): AdminDataPayload { + if (Array.isArray(input)) { + return normalizePayloadObject({ accounts: input }, exportedAt) + } + + if (!isRecord(input)) { + throw new Error('Unsupported import payload') + } + + if (isRecord(input.data)) { + return normalizePayloadObject(input.data, exportedAt) + } + + if (Array.isArray(input.accounts) || Array.isArray(input.proxies)) { + return normalizePayloadObject(input, exportedAt) + } + + if ('name' in input && 'platform' in input && 'type' in input) { + return normalizePayloadObject({ accounts: [input] }, exportedAt) + } + + throw new Error('Unsupported import payload') +} + +export function mergeAccountImportPayloads( + payloads: AdminDataPayload[], + exportedAt: string = new Date().toISOString() +): AdminDataPayload { + return { + type: DEFAULT_DATA_TYPE, + version: DEFAULT_DATA_VERSION, + exported_at: exportedAt, + proxies: dedupeProxies(payloads.flatMap((payload) => payload.proxies)), + accounts: payloads.flatMap((payload) => payload.accounts) + } +}