From 83455a3feebeca6c3c7b323ca5aba030fb11adef Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=99=BD=E5=96=B5=E5=96=B5=E5=96=B5=E5=96=B5?=
<3299332656@qq.com>
Date: Sun, 5 Jul 2026 19:31:06 +0800
Subject: [PATCH] fix(frontend): harden account data batch import
---
.../__tests__/integration/data-import.spec.ts | 191 +++++++++++++-----
.../admin/account/ImportDataModal.vue | 104 +++++++---
frontend/src/i18n/locales/en.ts | 3 +
frontend/src/i18n/locales/zh.ts | 3 +
4 files changed, 217 insertions(+), 84 deletions(-)
diff --git a/frontend/src/__tests__/integration/data-import.spec.ts b/frontend/src/__tests__/integration/data-import.spec.ts
index 5be8852c7f..1decee6760 100644
--- a/frontend/src/__tests__/integration/data-import.spec.ts
+++ b/frontend/src/__tests__/integration/data-import.spec.ts
@@ -4,11 +4,13 @@ import ImportDataModal from '@/components/admin/account/ImportDataModal.vue'
const showError = vi.fn()
const showSuccess = vi.fn()
+const showWarning = vi.fn()
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError,
- showSuccess
+ showSuccess,
+ showWarning
})
}))
@@ -26,50 +28,110 @@ vi.mock('vue-i18n', () => ({
})
}))
+const mountModal = () =>
+ mount(ImportDataModal, {
+ props: { show: true },
+ global: {
+ stubs: {
+ BaseDialog: { template: '
' }
+ }
+ }
+ })
+
+const makeJsonFile = (name: string, content: string, type = 'application/json') => {
+ const file = new File([content], name, { type })
+ Object.defineProperty(file, 'text', {
+ value: () => Promise.resolve(content)
+ })
+ return file
+}
+
+const setInputFiles = (element: Element, files: File[]) => {
+ Object.defineProperty(element, 'files', {
+ value: files,
+ configurable: true
+ })
+}
+
describe('ImportDataModal', () => {
- beforeEach(() => {
+ beforeEach(async () => {
showError.mockReset()
showSuccess.mockReset()
+ showWarning.mockReset()
+ const { adminAPI } = await import('@/api/admin')
+ vi.mocked(adminAPI.accounts.importData).mockReset()
})
it('未选择文件时提示错误', async () => {
- const wrapper = mount(ImportDataModal, {
- props: { show: true },
- global: {
- stubs: {
- BaseDialog: { template: '
' }
- }
- }
- })
+ const wrapper = mountModal()
await wrapper.find('form').trigger('submit')
expect(showError).toHaveBeenCalledWith('admin.accounts.dataImportSelectFile')
})
- it('无效 JSON 时提示解析失败', async () => {
- const wrapper = mount(ImportDataModal, {
- props: { show: true },
- global: {
- stubs: {
- BaseDialog: { template: '
' }
- }
- }
- })
+ it('无效 JSON 时按文件名提示解析失败', async () => {
+ const { adminAPI } = await import('@/api/admin')
+ const wrapper = mountModal()
const input = wrapper.find('input[type="file"]')
- const file = new File(['invalid json'], 'data.json', { type: 'application/json' })
- Object.defineProperty(file, 'text', {
- value: () => Promise.resolve('invalid json')
- })
- Object.defineProperty(input.element, 'files', {
- value: [file]
- })
+ setInputFiles(input.element, [makeJsonFile('data.json', 'invalid json')])
await input.trigger('change')
await wrapper.find('form').trigger('submit')
- await Promise.resolve()
+ await flushPromises()
- expect(showError).toHaveBeenCalledWith('admin.accounts.dataImportParseFailed')
+ expect(showError).toHaveBeenCalledWith('admin.accounts.dataImportParseFailedFile')
+ expect(adminAPI.accounts.importData).not.toHaveBeenCalled()
+ })
+
+ it('不是导出数据的 JSON 按文件名拒绝', async () => {
+ const { adminAPI } = await import('@/api/admin')
+ const wrapper = mountModal()
+
+ const input = wrapper.find('input[type="file"]')
+ setInputFiles(input.element, [makeJsonFile('random.json', JSON.stringify({ name: 'test' }))])
+
+ await input.trigger('change')
+ await wrapper.find('form').trigger('submit')
+ await flushPromises()
+
+ expect(showError).toHaveBeenCalledWith('admin.accounts.dataImportInvalidFile')
+ expect(adminAPI.accounts.importData).not.toHaveBeenCalled()
+ })
+
+ it('无有效 JSON 的选择不清空已有选择', async () => {
+ const { adminAPI } = await import('@/api/admin')
+ vi.mocked(adminAPI.accounts.importData).mockResolvedValue({
+ proxy_created: 0,
+ proxy_reused: 0,
+ proxy_failed: 0,
+ account_created: 1,
+ account_failed: 0
+ })
+
+ const wrapper = mountModal()
+ const input = wrapper.find('input[type="file"]')
+
+ const valid = makeJsonFile(
+ 'valid.json',
+ JSON.stringify({ exported_at: '2026-07-05T00:00:00Z', proxies: [], accounts: [{ name: 'a' }] })
+ )
+ setInputFiles(input.element, [valid])
+ await input.trigger('change')
+
+ setInputFiles(input.element, [new File(['hello'], 'notes.txt', { type: 'text/plain' })])
+ await input.trigger('change')
+ expect(showError).toHaveBeenCalledWith('admin.accounts.dataImportSelectFile')
+
+ await wrapper.find('form').trigger('submit')
+ await flushPromises()
+
+ expect(adminAPI.accounts.importData).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ accounts: [{ name: 'a' }]
+ }),
+ skip_default_group_bind: true
+ })
})
it('merges multiple selected JSON files before importing', async () => {
@@ -82,32 +144,22 @@ describe('ImportDataModal', () => {
account_failed: 0
})
- const wrapper = mount(ImportDataModal, {
- props: { show: true },
- global: {
- stubs: {
- BaseDialog: { template: '
' }
- }
- }
- })
+ const wrapper = mountModal()
const input = wrapper.find('input[type="file"]')
- const first = new File([
+ const first = makeJsonFile(
+ 'first.json',
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]
- })
+ )
+ const second = makeJsonFile(
+ 'second.json',
+ JSON.stringify({
+ exported_at: '2026-07-05T00:00:01Z',
+ proxies: [{ proxy_key: 'p' }],
+ accounts: [{ name: 'b' }]
+ })
+ )
+ setInputFiles(input.element, [first, second])
await input.trigger('change')
await wrapper.find('form').trigger('submit')
@@ -122,4 +174,41 @@ describe('ImportDataModal', () => {
})
expect(showSuccess).toHaveBeenCalledWith('admin.accounts.dataImportSuccess')
})
+
+ it('部分成功时关闭弹窗仍通知父组件刷新', async () => {
+ const { adminAPI } = await import('@/api/admin')
+ vi.mocked(adminAPI.accounts.importData).mockResolvedValue({
+ proxy_created: 0,
+ proxy_reused: 0,
+ proxy_failed: 0,
+ account_created: 1,
+ account_failed: 1
+ })
+
+ const wrapper = mountModal()
+ const input = wrapper.find('input[type="file"]')
+ setInputFiles(input.element, [
+ makeJsonFile(
+ 'mixed.json',
+ JSON.stringify({
+ exported_at: '2026-07-05T00:00:00Z',
+ proxies: [],
+ accounts: [{ name: 'a' }, { name: 'b' }]
+ })
+ )
+ ])
+
+ await input.trigger('change')
+ await wrapper.find('form').trigger('submit')
+ await flushPromises()
+
+ expect(showError).toHaveBeenCalledWith('admin.accounts.dataImportCompletedWithErrors')
+ expect(wrapper.emitted('imported')).toBeUndefined()
+
+ // 第二个 btn-secondary 是 footer 的取消按钮(第一个是选择文件)
+ await wrapper.findAll('button.btn-secondary')[1]!.trigger('click')
+
+ expect(wrapper.emitted('imported')).toHaveLength(1)
+ expect(wrapper.emitted('close')).toHaveLength(1)
+ })
})
diff --git a/frontend/src/components/admin/account/ImportDataModal.vue b/frontend/src/components/admin/account/ImportDataModal.vue
index 7ede8241a8..a0bfe294f6 100644
--- a/frontend/src/components/admin/account/ImportDataModal.vue
+++ b/frontend/src/components/admin/account/ImportDataModal.vue
@@ -24,7 +24,7 @@
? '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"
+ @dragover.prevent
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
>
@@ -101,7 +101,7 @@ import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import { adminAPI } from '@/api/admin'
import { useAppStore } from '@/stores/app'
-import type { AdminDataImportResult } from '@/types'
+import type { AdminDataImportResult, AdminDataPayload } from '@/types'
interface Props {
show: boolean
@@ -120,8 +120,9 @@ const appStore = useAppStore()
const importing = ref(false)
const files = ref([])
-const dragActive = ref(false)
const dragDepth = ref(0)
+const dragActive = computed(() => dragDepth.value > 0)
+const hasCreatedData = ref(false)
const result = ref(null)
const fileInput = ref(null)
@@ -139,8 +140,8 @@ watch(
(open) => {
if (open) {
files.value = []
- dragActive.value = false
dragDepth.value = 0
+ hasCreatedData.value = false
result.value = null
if (fileInput.value) {
fileInput.value.value = ''
@@ -156,10 +157,15 @@ const openFilePicker = () => {
const handleFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
setSelectedFiles(target.files)
+ target.value = ''
}
const handleClose = () => {
if (importing.value) return
+ if (hasCreatedData.value) {
+ hasCreatedData.value = false
+ emit('imported')
+ }
emit('close')
}
@@ -170,12 +176,17 @@ const isJsonFile = (sourceFile: File) => {
const setSelectedFiles = (sourceFiles: FileList | File[] | null | undefined) => {
if (importing.value) return
- const picked = Array.from(sourceFiles || []).filter(isJsonFile)
+ const incoming = Array.from(sourceFiles || [])
+ const picked = incoming.filter(isJsonFile)
if (!picked.length) {
- files.value = []
appStore.showError(t('admin.accounts.dataImportSelectFile'))
return
}
+ if (picked.length < incoming.length) {
+ appStore.showWarning(
+ t('admin.accounts.dataImportIgnoredFiles', { count: incoming.length - picked.length })
+ )
+ }
files.value = picked
result.value = null
}
@@ -183,26 +194,15 @@ const setSelectedFiles = (sourceFiles: FileList | File[] | null | undefined) =>
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
+ if (importing.value) return
setSelectedFiles(event.dataTransfer?.files)
}
@@ -224,17 +224,43 @@ const readFileAsText = async (sourceFile: File): Promise => {
})
}
-const mergeDataPayloads = (payloads: any[]) => {
- if (payloads.length === 1) return payloads[0]
+const SUPPORTED_DATA_TYPES = ['sub2api-data', 'sub2api-bundle']
+const SUPPORTED_DATA_VERSION = 1
+
+// 与后端 validateDataHeader 对齐:合并前逐文件校验,避免坏文件混入合并 payload 后
+// 报错无法定位来源,或绕过后端本会对单文件做的 type/version 检查。
+const isValidDataPayload = (payload: unknown): payload is AdminDataPayload => {
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false
+ const candidate = payload as Record
+ if (
+ candidate.type !== undefined &&
+ candidate.type !== '' &&
+ !SUPPORTED_DATA_TYPES.includes(candidate.type as string)
+ ) {
+ return false
+ }
+ if (
+ candidate.version !== undefined &&
+ candidate.version !== 0 &&
+ candidate.version !== SUPPORTED_DATA_VERSION
+ ) {
+ return false
+ }
+ return Array.isArray(candidate.proxies) && Array.isArray(candidate.accounts)
+}
+
+const mergeDataPayloads = (payloads: AdminDataPayload[]): AdminDataPayload => {
+ const [firstPayload] = payloads
+ if (payloads.length === 1 && firstPayload) return firstPayload
return {
- type: payloads.find((item) => typeof item?.type === 'string')?.type,
- version: payloads.find((item) => typeof item?.version === 'number')?.version,
+ 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 : []),
+ proxies: payloads.flatMap((item) => item.proxies),
+ accounts: payloads.flatMap((item) => item.accounts),
skipped_shadows: payloads.reduce((sum, item) => {
- const count = Number(item?.skipped_shadows || 0)
+ const count = Number(item.skipped_shadows || 0)
return Number.isFinite(count) ? sum + count : sum
}, 0)
}
@@ -248,10 +274,22 @@ const handleImport = async () => {
importing.value = true
try {
- const dataPayloads = []
+ const dataPayloads: AdminDataPayload[] = []
for (const sourceFile of files.value) {
- const text = await readFileAsText(sourceFile)
- dataPayloads.push(JSON.parse(text))
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(await readFileAsText(sourceFile))
+ } catch {
+ appStore.showError(
+ t('admin.accounts.dataImportParseFailedFile', { name: sourceFile.name })
+ )
+ return
+ }
+ if (!isValidDataPayload(parsed)) {
+ appStore.showError(t('admin.accounts.dataImportInvalidFile', { name: sourceFile.name }))
+ return
+ }
+ dataPayloads.push(parsed)
}
const dataPayload = mergeDataPayloads(dataPayloads)
@@ -270,17 +308,17 @@ const handleImport = async () => {
proxy_failed: res.proxy_failed,
}
if (res.account_failed > 0 || res.proxy_failed > 0) {
+ // 部分成功也创建了数据;弹窗关闭时通过 imported 通知父组件刷新列表
+ if (res.account_created > 0 || res.proxy_created > 0) {
+ hasCreatedData.value = true
+ }
appStore.showError(t('admin.accounts.dataImportCompletedWithErrors', msgParams))
} else {
appStore.showSuccess(t('admin.accounts.dataImportSuccess', msgParams))
emit('imported')
}
} catch (error: any) {
- if (error instanceof SyntaxError) {
- appStore.showError(t('admin.accounts.dataImportParseFailed'))
- } else {
- appStore.showError(error?.message || t('admin.accounts.dataImportFailed'))
- }
+ appStore.showError(error?.message || t('admin.accounts.dataImportFailed'))
} finally {
importing.value = false
}
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index ac831278bd..7e4a29c799 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -3150,6 +3150,9 @@ export default {
dataImporting: 'Importing...',
dataImportSelectFile: 'Please select a data file',
dataImportParseFailed: 'Failed to parse data file',
+ dataImportParseFailedFile: 'Failed to parse {name}',
+ dataImportInvalidFile: '{name} is not a supported data export file',
+ dataImportIgnoredFiles: 'Ignored {count} non-JSON file(s)',
dataImportFailed: 'Data import failed',
dataImportResult: 'Import Result',
dataImportResultSummary: 'Proxies created {proxy_created}, reused {proxy_reused}, failed {proxy_failed}; Accounts created {account_created}, failed {account_failed}',
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index dc090a458f..cc65a0c0e0 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -3225,6 +3225,9 @@ export default {
dataImporting: '导入中...',
dataImportSelectFile: '请选择数据文件',
dataImportParseFailed: '数据解析失败',
+ dataImportParseFailedFile: '文件 {name} 解析失败',
+ dataImportInvalidFile: '文件 {name} 不是受支持的导出数据文件',
+ dataImportIgnoredFiles: '已忽略 {count} 个非 JSON 文件',
dataImportFailed: '数据导入失败',
dataImportResult: '导入结果',
dataImportResultSummary: '代理创建 {proxy_created},复用 {proxy_reused},失败 {proxy_failed};账号创建 {account_created},失败 {account_failed}',