Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 191 additions & 3 deletions frontend/src/__tests__/integration/data-import.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -15,7 +20,7 @@ vi.mock('@/stores/app', () => ({
vi.mock('@/api/admin', () => ({
adminAPI: {
accounts: {
importData: vi.fn()
importData
}
}
}))
Expand All @@ -30,6 +35,7 @@ describe('ImportDataModal', () => {
beforeEach(() => {
showError.mockReset()
showSuccess.mockReset()
importData.mockReset()
})

it('未选择文件时提示错误', async () => {
Expand Down Expand Up @@ -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: '<div><slot /><slot name="footer" /></div>' }
}
}
})

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: '<div><slot /><slot name="footer" /></div>' }
}
}
})

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
})
})
})
25 changes: 18 additions & 7 deletions frontend/src/components/admin/account/ImportDataModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
type="file"
class="hidden"
accept="application/json,.json"
multiple
@change="handleFileChange"
/>
</div>
Expand Down Expand Up @@ -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
Expand All @@ -108,19 +110,23 @@ const { t } = useI18n()
const appStore = useAppStore()

const importing = ref(false)
const file = ref<File | null>(null)
const files = ref<File[]>([])
const result = ref<AdminDataImportResult | null>(null)

const fileInput = ref<HTMLInputElement | null>(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 || [])

watch(
() => props.show,
(open) => {
if (open) {
file.value = null
files.value = []
result.value = null
if (fileInput.value) {
fileInput.value.value = ''
Expand All @@ -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 = () => {
Expand All @@ -162,15 +168,20 @@ const readFileAsText = async (sourceFile: File): Promise<string> => {
}

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,
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/i18n/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1930,7 +1930,8 @@ export default {
dataExported: '数据导出成功',
dataExportFailed: '数据导出失败',
dataImportTitle: '导入数据',
dataImportHint: '上传导出的 JSON 文件以批量导入账号与代理。',
dataImportHint: '可一次选择一个或多个 JSON 文件;支持导出文件、账号数组 JSON、以及包含 accounts 的 JSON 对象。',
dataImportSelectedFiles: '已选择 {count} 个文件',
dataImportWarning: '导入将创建新账号与代理,分组需手工绑定;请确认已有数据不会冲突。',
dataImportFile: '数据文件',
dataImportButton: '开始导入',
Expand Down
Loading