Merge pull request #3729 from starmiaoa/feat/account-import-dnd-batch-20260705

支持账号数据拖拽和批量导入
This commit is contained in:
Wesley Liddick
2026-07-07 10:44:39 +08:00
committed by GitHub
4 changed files with 313 additions and 46 deletions
@@ -1,14 +1,16 @@
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()
const showSuccess = vi.fn()
const showWarning = vi.fn()
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError,
showSuccess
showSuccess,
showWarning
})
}))
@@ -26,49 +28,187 @@ vi.mock('vue-i18n', () => ({
})
}))
const mountModal = () =>
mount(ImportDataModal, {
props: { show: true },
global: {
stubs: {
BaseDialog: { template: '<div><slot /><slot name="footer" /></div>' }
}
}
})
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: '<div><slot /><slot name="footer" /></div>' }
}
}
})
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: '<div><slot /><slot name="footer" /></div>' }
}
}
})
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 () => {
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 = mountModal()
const input = wrapper.find('input[type="file"]')
const first = makeJsonFile(
'first.json',
JSON.stringify({ exported_at: '2026-07-05T00:00:00Z', proxies: [], accounts: [{ name: 'a' }] })
)
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')
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')
})
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)
})
})
@@ -19,13 +19,23 @@
<div>
<label class="input-label">{{ t('admin.accounts.dataImportFile') }}</label>
<div
class="flex items-center justify-between gap-3 rounded-lg border border-dashed border-gray-300 bg-gray-50 px-4 py-3 dark:border-dark-600 dark:bg-dark-800"
class="flex items-center justify-between gap-3 rounded-lg border border-dashed px-4 py-3 transition-colors"
:class="dragActive
? '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
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
>
<div class="min-w-0">
<div class="truncate text-sm text-gray-700 dark:text-dark-200">
{{ fileName || t('admin.accounts.dataImportSelectFile') }}
<div class="truncate text-sm text-gray-700 dark:text-dark-200" :title="fileListTitle">
{{ selectedFilesLabel || t('admin.accounts.dataImportSelectFile') }}
</div>
<div class="text-xs text-gray-500 dark:text-dark-400">
JSON (.json)
<span v-if="files.length > 1"> · {{ fileListTitle }}</span>
</div>
<div class="text-xs text-gray-500 dark:text-dark-400">JSON (.json)</div>
</div>
<button type="button" class="btn btn-secondary shrink-0" @click="openFilePicker">
{{ t('common.chooseFile') }}
@@ -36,6 +46,7 @@
type="file"
class="hidden"
accept="application/json,.json"
multiple
@change="handleFileChange"
/>
</div>
@@ -90,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
@@ -108,11 +119,19 @@ const { t } = useI18n()
const appStore = useAppStore()
const importing = ref(false)
const file = ref<File | null>(null)
const files = ref<File[]>([])
const dragDepth = ref(0)
const dragActive = computed(() => dragDepth.value > 0)
const hasCreatedData = ref(false)
const result = ref<AdminDataImportResult | null>(null)
const fileInput = ref<HTMLInputElement | null>(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 +139,9 @@ watch(
() => props.show,
(open) => {
if (open) {
file.value = null
files.value = []
dragDepth.value = 0
hasCreatedData.value = false
result.value = null
if (fileInput.value) {
fileInput.value.value = ''
@@ -135,14 +156,56 @@ const openFilePicker = () => {
const handleFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
file.value = target.files?.[0] || null
setSelectedFiles(target.files)
target.value = ''
}
const handleClose = () => {
if (importing.value) return
if (hasCreatedData.value) {
hasCreatedData.value = false
emit('imported')
}
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 incoming = Array.from(sourceFiles || [])
const picked = incoming.filter(isJsonFile)
if (!picked.length) {
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
}
const handleDragEnter = () => {
if (importing.value) return
dragDepth.value += 1
}
const handleDragLeave = () => {
dragDepth.value = Math.max(0, dragDepth.value - 1)
}
const handleDrop = (event: DragEvent) => {
dragDepth.value = 0
if (importing.value) return
setSelectedFiles(event.dataTransfer?.files)
}
const readFileAsText = async (sourceFile: File): Promise<string> => {
if (typeof sourceFile.text === 'function') {
return sourceFile.text()
@@ -161,16 +224,74 @@ const readFileAsText = async (sourceFile: File): Promise<string> => {
})
}
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<string, unknown>
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,
exported_at: new Date().toISOString(),
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)
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: AdminDataPayload[] = []
for (const sourceFile of files.value) {
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)
const res = await adminAPI.accounts.importData({
data: dataPayload,
@@ -187,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
}
+3
View File
@@ -3151,6 +3151,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}',
+3
View File
@@ -3226,6 +3226,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}',