From 728bb1bc9d0b47988ec9a96998959ac08eae5693 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 18:58:57 +0800 Subject: [PATCH 01/19] =?UTF-8?q?feat(frontend):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E6=95=B0=E6=8D=AE=E6=8B=96=E6=8B=BD=E5=92=8C?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/integration/data-import.spec.ts | 53 ++++++++- .../admin/account/ImportDataModal.vue | 105 ++++++++++++++++-- 2 files changed, 146 insertions(+), 12 deletions(-) diff --git a/frontend/src/__tests__/integration/data-import.spec.ts b/frontend/src/__tests__/integration/data-import.spec.ts index bc9de148bd..5be8852c7f 100644 --- a/frontend/src/__tests__/integration/data-import.spec.ts +++ b/frontend/src/__tests__/integration/data-import.spec.ts @@ -1,5 +1,5 @@ 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() @@ -71,4 +71,55 @@ describe('ImportDataModal', () => { expect(showError).toHaveBeenCalledWith('admin.accounts.dataImportParseFailed') }) + + 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 = mount(ImportDataModal, { + props: { show: true }, + global: { + stubs: { + BaseDialog: { template: '
' } + } + } + }) + + const input = wrapper.find('input[type="file"]') + const first = new File([ + 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] + }) + + 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') + }) }) diff --git a/frontend/src/components/admin/account/ImportDataModal.vue b/frontend/src/components/admin/account/ImportDataModal.vue index 6c120be39a..7ede8241a8 100644 --- a/frontend/src/components/admin/account/ImportDataModal.vue +++ b/frontend/src/components/admin/account/ImportDataModal.vue @@ -19,13 +19,23 @@
-
- {{ fileName || t('admin.accounts.dataImportSelectFile') }} +
+ {{ selectedFilesLabel || t('admin.accounts.dataImportSelectFile') }} +
+
+ JSON (.json) + · {{ fileListTitle }}
-
JSON (.json)
@@ -108,11 +119,18 @@ const { t } = useI18n() const appStore = useAppStore() const importing = ref(false) -const file = ref(null) +const files = ref([]) +const dragActive = ref(false) +const dragDepth = ref(0) const result = ref(null) const fileInput = ref(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 +138,9 @@ watch( () => props.show, (open) => { if (open) { - file.value = null + files.value = [] + dragActive.value = false + dragDepth.value = 0 result.value = null if (fileInput.value) { fileInput.value.value = '' @@ -135,7 +155,7 @@ const openFilePicker = () => { const handleFileChange = (event: Event) => { const target = event.target as HTMLInputElement - file.value = target.files?.[0] || null + setSelectedFiles(target.files) } const handleClose = () => { @@ -143,6 +163,49 @@ const handleClose = () => { 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 picked = Array.from(sourceFiles || []).filter(isJsonFile) + if (!picked.length) { + files.value = [] + appStore.showError(t('admin.accounts.dataImportSelectFile')) + return + } + files.value = picked + result.value = null +} + +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 + setSelectedFiles(event.dataTransfer?.files) +} + const readFileAsText = async (sourceFile: File): Promise => { if (typeof sourceFile.text === 'function') { return sourceFile.text() @@ -161,16 +224,36 @@ const readFileAsText = async (sourceFile: File): Promise => { }) } +const mergeDataPayloads = (payloads: any[]) => { + if (payloads.length === 1) return payloads[0] + + 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) => Array.isArray(item?.proxies) ? item.proxies : []), + accounts: payloads.flatMap((item) => Array.isArray(item?.accounts) ? 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 = [] + for (const sourceFile of files.value) { + const text = await readFileAsText(sourceFile) + dataPayloads.push(JSON.parse(text)) + } + const dataPayload = mergeDataPayloads(dataPayloads) const res = await adminAPI.accounts.importData({ data: dataPayload, 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 02/19] 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}', From a42e9e3fc808786661eefd669cb8cd9a4272db0c Mon Sep 17 00:00:00 2001 From: Heatherm Huang Date: Mon, 6 Jul 2026 17:02:28 +0800 Subject: [PATCH 03/19] fix: expose Grok image pricing controls --- frontend/src/views/admin/GroupsView.vue | 13 +++---------- .../admin/__tests__/groupsImagePricing.spec.ts | 17 +++++++++++++++++ frontend/src/views/admin/groupsImagePricing.ts | 9 +++++++++ 3 files changed, 29 insertions(+), 10 deletions(-) create mode 100644 frontend/src/views/admin/__tests__/groupsImagePricing.spec.ts create mode 100644 frontend/src/views/admin/groupsImagePricing.ts diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index 56d21c86c1..1901971e45 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -789,11 +789,7 @@