diff --git a/frontend/src/views/accounts/components/AccountCreateModal.vue b/frontend/src/views/accounts/components/AccountCreateModal.vue index 9bc81a108..e0af7fa6b 100644 --- a/frontend/src/views/accounts/components/AccountCreateModal.vue +++ b/frontend/src/views/accounts/components/AccountCreateModal.vue @@ -2,7 +2,7 @@ import type { useAccountOnboarding } from '../composables/useAccountOnboarding' import type { AccountRow } from '../constants' import { Openai, Xai } from '@boxicons/vue' -import { Copy, KeyRound, Upload } from '@lucide/vue' +import { Copy, Grid2X2, KeyRound, Upload } from '@lucide/vue' import { useClipboard, useFileDialog } from '@vueuse/core' import { computed, ref } from 'vue' @@ -51,7 +51,7 @@ const { open: openImportFile, onChange: onImportFileChange } = useFileDialog({ }) const modeOptions = computed(() => accountProviderModeOptions(form.value.provider)) -const isProviderSelected = computed(() => form.value.provider === 'openai' || form.value.provider === 'xai') +const isProviderSelected = computed(() => ['openai', 'xai', 'batch'].includes(form.value.provider)) const isChoosingProvider = computed(() => !props.reauthorizing && !isProviderSelected.value) const provider = computed({ @@ -91,10 +91,17 @@ const oauthCallback = computed({ const oauthAuthUrl = computed(() => form.value.oauthAuthUrl || '') const isXai = computed(() => form.value.provider === 'xai') -const importFileLabel = computed(() => mode.value === 'agent_identity' ? 'Agent 身份文件' : '账号文件') +const isBatch = computed(() => form.value.provider === 'batch') +const importFileLabel = computed(() => { + if (isBatch.value) + return '批量账号文件' + return mode.value === 'agent_identity' ? 'Agent 身份文件' : '账号文件' +}) const importFilePlaceholder = computed(() => mode.value === 'agent_identity' ? '粘贴 Agent 身份文件内容' - : '粘贴 CPR、Sub2API 或 CPA 账号文件内容') + : isBatch.value + ? '粘贴 CPR 多平台导出文件内容' + : '粘贴 CPR、Sub2API 或 CPA 账号文件内容') const accountName = computed(() => { return props.account?.email || props.account?.accountId || props.account?.id || '该账号' @@ -140,6 +147,8 @@ const description = computed(() => { return undefined if (props.reauthorizing) return '完成授权后粘贴回调地址,系统会更新账号凭据' + if (isBatch.value) + return '导入 CPR 多平台账号包,系统会按文档中的平台自动分流' if (isXai.value) { return mode.value === 'oauth' ? '复制 xAI 授权链接,完成后粘贴回调地址,不使用 xAI API Key' @@ -152,7 +161,7 @@ const description = computed(() => { return '导入 CPR、Sub2API 或 CPA 账号文件,已存在账号会更新' }) -function selectProvider(value: 'openai' | 'xai') { +function selectProvider(value: 'openai' | 'xai' | 'batch') { provider.value = value } @@ -198,7 +207,8 @@ async function copyText(value: string, successText: string) { :hide-footer="isChoosingProvider" > @@ -209,7 +219,12 @@ async function copyText(value: string, successText: string) { />
- +
@@ -316,7 +331,7 @@ async function copyText(value: string, successText: string) { :disabled="!canSubmit" @click="emit('create')" > - {{ reauthorizing ? '完成重新授权' : mode === 'oauth' ? '完成授权导入' : '导入' }} + {{ reauthorizing ? '完成重新授权' : mode === 'oauth' ? '完成授权导入' : isBatch ? '批量导入' : '导入' }} diff --git a/frontend/src/views/accounts/components/AccountProviderChooser.vue b/frontend/src/views/accounts/components/AccountProviderChooser.vue index 6aa00ced3..2390bb682 100644 --- a/frontend/src/views/accounts/components/AccountProviderChooser.vue +++ b/frontend/src/views/accounts/components/AccountProviderChooser.vue @@ -1,5 +1,6 @@ diff --git a/frontend/src/views/accounts/composables/useAccountOnboarding.ts b/frontend/src/views/accounts/composables/useAccountOnboarding.ts index c03f2004b..8776b6f2c 100644 --- a/frontend/src/views/accounts/composables/useAccountOnboarding.ts +++ b/frontend/src/views/accounts/composables/useAccountOnboarding.ts @@ -11,6 +11,12 @@ import { toast } from '@/components/base/BaseToast' import { useAsyncAction } from '@/composables/useAsyncAction' type AccountRow = Awaited>['items'][number] +type ImportProvider = 'openai' | 'xai' + +interface MixedImportDocument { + provider: ImportProvider + document: Record +} export function useAccountOnboarding(options: { reload: () => Promise @@ -46,10 +52,10 @@ export function useAccountOnboarding(options: { await creatingAccountAction.run( async () => { - await importAccountDocument() - await finishCreate( - createForm.value.provider === 'xai' ? 'xAI OAuth 账号已导入' : 'OpenAI 账号已导入', - ) + const message = createForm.value.provider === 'batch' + ? await importMixedAccountDocument() + : await importAccountDocument() + await finishCreate(message) }, { errorText: '导入失败' }, ) @@ -170,6 +176,41 @@ export function useAccountOnboarding(options: { data, })) commitConfigRevision(result.configRevision) + return createForm.value.provider === 'xai' ? 'xAI 账号已导入' : 'OpenAI 账号已导入' + } + + async function importMixedAccountDocument() { + const documents = parseMixedImportDocuments(parseImportJson(createForm.value.importText)) + let importedCount = 0 + const failures: string[] = [] + + for (const entry of documents) { + try { + const expectedConfigRevision = await requireConfigRevision() + const result = await withConflictRefresh(() => importAccounts({ + provider: entry.provider, + expectedConfigRevision, + data: entry.document, + })) + commitConfigRevision(result.configRevision) + importedCount += result.importedCount + } + catch (error) { + const message = error instanceof Error && error.message + ? error.message + : '导入失败' + failures.push(`${entry.provider === 'xai' ? 'xAI' : 'OpenAI'}:${message}`) + } + } + + if (importedCount === 0) { + throw new Error(failures.length > 0 ? `批量导入失败:${failures.join(';')}` : '批量文件没有可导入的账号') + } + + if (failures.length > 0) { + return `已导入 ${importedCount} 个账号,${failures.length} 个文档失败:${failures.join(';')}` + } + return `已导入 ${importedCount} 个账号` } async function finishCreate(message: string) { @@ -195,7 +236,7 @@ export function useAccountOnboarding(options: { () => { createForm.value = { ...createForm.value, - mode: 'oauth', + mode: createForm.value.provider === 'batch' ? 'json' : 'oauth', oauthFlowId: '', oauthAuthUrl: '', oauthCallback: '', @@ -255,3 +296,28 @@ function parseImportJson(value: string) { throw new Error('JSON 格式不正确') } } + +function parseMixedImportDocuments(value: unknown): MixedImportDocument[] { + if (!isRecord(value) || !Array.isArray(value.documents)) + throw new Error('批量导入文件必须是 CPR 多平台导出文件') + + const documents: MixedImportDocument[] = [] + for (const entry of value.documents) { + if (!isRecord(entry)) + throw new Error('批量导入文件包含无效的 Provider 文档') + const provider = entry.provider + if (provider !== 'openai' && provider !== 'xai') + throw new Error('批量导入文件包含无效的 Provider 文档') + if (!isRecord(entry.document)) + throw new Error('批量导入文件包含无效的 Provider 文档') + documents.push({ provider, document: entry.document }) + } + + if (documents.length === 0) + throw new Error('批量文件没有可导入的账号文档') + return documents +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +}