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)
+ }
+}