feat(ui): OpenAI OAuth 账号支持批量 RT 输入创建

新增通过手动输入 Refresh Token 创建 OpenAI OAuth 账号功能,
参考 Anthropic sessionKey 批量创建方式:

- useOpenAIOAuth 添加 validateRefreshToken 方法
- accounts.ts 添加 refreshOpenAIToken API
- AuthInputMethod 类型新增 refresh_token 选项
- 支持多行输入 RT(每行一个)批量创建账号
- 账号名称自动累加后缀 #1, #2 等
- UI 显示 RT 数量徽章和批量创建提示
- 添加中英文 i18n 翻译
This commit is contained in:
shaw
2026-02-08 16:10:15 +08:00
parent 2185a3b674
commit 8a0a8558cf
7 changed files with 285 additions and 7 deletions

View File

@@ -398,6 +398,26 @@ export async function getAntigravityDefaultModelMapping(): Promise<Record<string
return data
}
/**
* Refresh OpenAI token using refresh token
* @param refreshToken - The refresh token
* @param proxyId - Optional proxy ID
* @returns Token information including access_token, email, etc.
*/
export async function refreshOpenAIToken(
refreshToken: string,
proxyId?: number | null
): Promise<Record<string, unknown>> {
const payload: { refresh_token: string; proxy_id?: number } = {
refresh_token: refreshToken
}
if (proxyId) {
payload.proxy_id = proxyId
}
const { data } = await apiClient.post<Record<string, unknown>>('/admin/openai/refresh-token', payload)
return data
}
export const accountsAPI = {
list,
getById,
@@ -418,6 +438,7 @@ export const accountsAPI = {
getAvailableModels,
generateAuthUrl,
exchangeCode,
refreshOpenAIToken,
batchCreate,
batchUpdateCredentials,
bulkUpdate,

View File

@@ -1650,10 +1650,12 @@
:show-proxy-warning="form.platform !== 'openai' && !!form.proxy_id"
:allow-multiple="form.platform === 'anthropic'"
:show-cookie-option="form.platform === 'anthropic'"
:show-refresh-token-option="form.platform === 'openai'"
:platform="form.platform"
:show-project-id="geminiOAuthType === 'code_assist'"
@generate-url="handleGenerateUrl"
@cookie-auth="handleCookieAuth"
@validate-refresh-token="handleOpenAIValidateRT"
/>
</div>
@@ -2010,6 +2012,7 @@ interface OAuthFlowExposed {
oauthState: string
projectId: string
sessionKey: string
refreshToken: string
inputMethod: AuthInputMethod
reset: () => void
}
@@ -2861,6 +2864,95 @@ const handleOpenAIExchange = async (authCode: string) => {
}
}
// OpenAI 手动 RT 批量验证和创建
const handleOpenAIValidateRT = async (refreshTokenInput: string) => {
if (!refreshTokenInput.trim()) return
// Parse multiple refresh tokens (one per line)
const refreshTokens = refreshTokenInput
.split('\n')
.map((rt) => rt.trim())
.filter((rt) => rt)
if (refreshTokens.length === 0) {
openaiOAuth.error.value = t('admin.accounts.oauth.openai.pleaseEnterRefreshToken')
return
}
openaiOAuth.loading.value = true
openaiOAuth.error.value = ''
let successCount = 0
let failedCount = 0
const errors: string[] = []
try {
for (let i = 0; i < refreshTokens.length; i++) {
try {
const tokenInfo = await openaiOAuth.validateRefreshToken(
refreshTokens[i],
form.proxy_id
)
if (!tokenInfo) {
failedCount++
errors.push(`#${i + 1}: ${openaiOAuth.error.value || 'Validation failed'}`)
openaiOAuth.error.value = ''
continue
}
const credentials = openaiOAuth.buildCredentials(tokenInfo)
const extra = openaiOAuth.buildExtraInfo(tokenInfo)
// Generate account name with index for batch
const accountName = refreshTokens.length > 1 ? `${form.name} #${i + 1}` : form.name
await adminAPI.accounts.create({
name: accountName,
notes: form.notes,
platform: 'openai',
type: 'oauth',
credentials,
extra,
proxy_id: form.proxy_id,
concurrency: form.concurrency,
priority: form.priority,
rate_multiplier: form.rate_multiplier,
group_ids: form.group_ids,
expires_at: form.expires_at,
auto_pause_on_expired: autoPauseOnExpired.value
})
successCount++
} catch (error: any) {
failedCount++
const errMsg = error.response?.data?.detail || error.message || 'Unknown error'
errors.push(`#${i + 1}: ${errMsg}`)
}
}
// Show results
if (successCount > 0 && failedCount === 0) {
appStore.showSuccess(
refreshTokens.length > 1
? t('admin.accounts.oauth.batchSuccess', { count: successCount })
: t('admin.accounts.accountCreated')
)
emit('created')
handleClose()
} else if (successCount > 0 && failedCount > 0) {
appStore.showWarning(
t('admin.accounts.oauth.batchPartialSuccess', { success: successCount, failed: failedCount })
)
openaiOAuth.error.value = errors.join('\n')
emit('created')
} else {
openaiOAuth.error.value = errors.join('\n')
appStore.showError(t('admin.accounts.oauth.batchFailed'))
}
} finally {
openaiOAuth.loading.value = false
}
}
// Gemini OAuth 授权码兑换
const handleGeminiExchange = async (authCode: string) => {
if (!authCode.trim() || !geminiOAuth.sessionId.value) return

View File

@@ -10,11 +10,11 @@
<h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200">{{ oauthTitle }}</h4>
<!-- Auth Method Selection -->
<div v-if="showCookieOption" class="mb-4">
<div v-if="showMethodSelection" class="mb-4">
<label class="mb-2 block text-sm font-medium text-blue-800 dark:text-blue-300">
{{ methodLabel }}
</label>
<div class="flex gap-4">
<div class="flex flex-wrap gap-4">
<label class="flex cursor-pointer items-center gap-2">
<input
v-model="inputMethod"
@@ -26,7 +26,7 @@
t('admin.accounts.oauth.manualAuth')
}}</span>
</label>
<label class="flex cursor-pointer items-center gap-2">
<label v-if="showCookieOption" class="flex cursor-pointer items-center gap-2">
<input
v-model="inputMethod"
type="radio"
@@ -37,6 +37,101 @@
t('admin.accounts.oauth.cookieAutoAuth')
}}</span>
</label>
<label v-if="showRefreshTokenOption" class="flex cursor-pointer items-center gap-2">
<input
v-model="inputMethod"
type="radio"
value="refresh_token"
class="text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm text-blue-900 dark:text-blue-200">{{
t('admin.accounts.oauth.openai.refreshTokenAuth')
}}</span>
</label>
</div>
</div>
<!-- Refresh Token Input (OpenAI only) -->
<div v-if="inputMethod === 'refresh_token'" class="space-y-4">
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
{{ t('admin.accounts.oauth.openai.refreshTokenDesc') }}
</p>
<!-- Refresh Token Input -->
<div class="mb-4">
<label
class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
>
<Icon name="key" size="sm" class="text-blue-500" />
Refresh Token
<span
v-if="parsedRefreshTokenCount > 1"
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
>
{{ t('admin.accounts.oauth.keysCount', { count: parsedRefreshTokenCount }) }}
</span>
</label>
<textarea
v-model="refreshTokenInput"
rows="3"
class="input w-full resize-y font-mono text-sm"
:placeholder="t('admin.accounts.oauth.openai.refreshTokenPlaceholder')"
></textarea>
<p
v-if="parsedRefreshTokenCount > 1"
class="mt-1 text-xs text-blue-600 dark:text-blue-400"
>
{{ t('admin.accounts.oauth.batchCreateAccounts', { count: parsedRefreshTokenCount }) }}
</p>
</div>
<!-- Error Message -->
<div
v-if="error"
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
>
<p class="whitespace-pre-line text-sm text-red-600 dark:text-red-400">
{{ error }}
</p>
</div>
<!-- Validate Button -->
<button
type="button"
class="btn btn-primary w-full"
:disabled="loading || !refreshTokenInput.trim()"
@click="handleValidateRefreshToken"
>
<svg
v-if="loading"
class="-ml-1 mr-2 h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<Icon v-else name="sparkles" size="sm" class="mr-2" />
{{
loading
? t('admin.accounts.oauth.openai.validating')
: t('admin.accounts.oauth.openai.validateAndCreate')
}}
</button>
</div>
</div>
@@ -173,7 +268,7 @@
</div>
<!-- Manual Authorization Flow -->
<div v-else class="space-y-4">
<div v-if="inputMethod === 'manual'" class="space-y-4">
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
{{ oauthFollowSteps }}
</p>
@@ -428,6 +523,7 @@ interface Props {
allowMultiple?: boolean
methodLabel?: string
showCookieOption?: boolean // Whether to show cookie auto-auth option
showRefreshTokenOption?: boolean // Whether to show refresh token input option (OpenAI only)
platform?: 'anthropic' | 'openai' | 'gemini' | 'antigravity' // Platform type for different UI/text
showProjectId?: boolean // New prop to control project ID visibility
}
@@ -442,6 +538,7 @@ const props = withDefaults(defineProps<Props>(), {
allowMultiple: false,
methodLabel: 'Authorization Method',
showCookieOption: true,
showRefreshTokenOption: false,
platform: 'anthropic',
showProjectId: true
})
@@ -450,6 +547,7 @@ const emit = defineEmits<{
'generate-url': []
'exchange-code': [code: string]
'cookie-auth': [sessionKey: string]
'validate-refresh-token': [refreshToken: string]
'update:inputMethod': [method: AuthInputMethod]
}>()
@@ -487,10 +585,14 @@ const oauthImportantNotice = computed(() => {
const inputMethod = ref<AuthInputMethod>(props.showCookieOption ? 'manual' : 'manual')
const authCodeInput = ref('')
const sessionKeyInput = ref('')
const refreshTokenInput = ref('')
const showHelpDialog = ref(false)
const oauthState = ref('')
const projectId = ref('')
// Computed: show method selection when either cookie or refresh token option is enabled
const showMethodSelection = computed(() => props.showCookieOption || props.showRefreshTokenOption)
// Clipboard
const { copied, copyToClipboard } = useClipboard()
@@ -502,6 +604,14 @@ const parsedKeyCount = computed(() => {
.filter((k) => k).length
})
// Computed: count of refresh tokens entered
const parsedRefreshTokenCount = computed(() => {
return refreshTokenInput.value
.split('\n')
.map((rt) => rt.trim())
.filter((rt) => rt).length
})
// Watchers
watch(inputMethod, (newVal) => {
emit('update:inputMethod', newVal)
@@ -563,18 +673,26 @@ const handleCookieAuth = () => {
}
}
const handleValidateRefreshToken = () => {
if (refreshTokenInput.value.trim()) {
emit('validate-refresh-token', refreshTokenInput.value.trim())
}
}
// Expose methods and state
defineExpose({
authCode: authCodeInput,
oauthState,
projectId,
sessionKey: sessionKeyInput,
refreshToken: refreshTokenInput,
inputMethod,
reset: () => {
authCodeInput.value = ''
oauthState.value = ''
projectId.value = ''
sessionKeyInput.value = ''
refreshTokenInput.value = ''
inputMethod.value = 'manual'
showHelpDialog.value = false
}

View File

@@ -3,7 +3,7 @@ import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
export type AddMethod = 'oauth' | 'setup-token'
export type AuthInputMethod = 'manual' | 'cookie'
export type AuthInputMethod = 'manual' | 'cookie' | 'refresh_token'
export interface OAuthState {
authUrl: string

View File

@@ -105,6 +105,32 @@ export function useOpenAIOAuth() {
}
}
// Validate refresh token and get full token info
const validateRefreshToken = async (
refreshToken: string,
proxyId?: number | null
): Promise<OpenAITokenInfo | null> => {
if (!refreshToken.trim()) {
error.value = 'Missing refresh token'
return null
}
loading.value = true
error.value = ''
try {
// Use dedicated refresh-token endpoint
const tokenInfo = await adminAPI.accounts.refreshOpenAIToken(refreshToken.trim(), proxyId)
return tokenInfo as OpenAITokenInfo
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to validate refresh token'
appStore.showError(error.value)
return null
} finally {
loading.value = false
}
}
// Build credentials for OpenAI OAuth account
const buildCredentials = (tokenInfo: OpenAITokenInfo): Record<string, unknown> => {
const creds: Record<string, unknown> = {
@@ -152,6 +178,7 @@ export function useOpenAIOAuth() {
resetState,
generateAuthUrl,
exchangeAuthCode,
validateRefreshToken,
buildCredentials,
buildExtraInfo
}

View File

@@ -1662,6 +1662,9 @@ export default {
cookieAuthFailed: 'Cookie authorization failed',
keyAuthFailed: 'Key {index}: {error}',
successCreated: 'Successfully created {count} account(s)',
batchSuccess: 'Successfully created {count} account(s)',
batchPartialSuccess: 'Partial success: {success} succeeded, {failed} failed',
batchFailed: 'Batch creation failed',
// OpenAI specific
openai: {
title: 'OpenAI Account Authorization',
@@ -1680,7 +1683,14 @@ export default {
authCodePlaceholder:
'Option 1: Copy the complete URL\n(http://localhost:xxx/auth/callback?code=...)\nOption 2: Copy only the code parameter value',
authCodeHint:
'You can copy the entire URL or just the code parameter value, the system will auto-detect'
'You can copy the entire URL or just the code parameter value, the system will auto-detect',
// Refresh Token auth
refreshTokenAuth: 'Manual RT Input',
refreshTokenDesc: 'Enter your existing OpenAI Refresh Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.',
refreshTokenPlaceholder: 'Paste your OpenAI Refresh Token...\nSupports multiple, one per line',
validating: 'Validating...',
validateAndCreate: 'Validate & Create Account',
pleaseEnterRefreshToken: 'Please enter Refresh Token'
},
// Gemini specific
gemini: {

View File

@@ -1804,6 +1804,9 @@ export default {
cookieAuthFailed: 'Cookie 授权失败',
keyAuthFailed: '密钥 {index}: {error}',
successCreated: '成功创建 {count} 个账号',
batchSuccess: '成功创建 {count} 个账号',
batchPartialSuccess: '部分成功:{success} 个成功,{failed} 个失败',
batchFailed: '批量创建失败',
// OpenAI specific
openai: {
title: 'OpenAI 账户授权',
@@ -1820,7 +1823,14 @@ export default {
authCode: '授权链接或 Code',
authCodePlaceholder:
'方式1复制完整的链接\n(http://localhost:xxx/auth/callback?code=...)\n方式2仅复制 code 参数的值',
authCodeHint: '您可以直接复制整个链接或仅复制 code 参数值,系统会自动识别'
authCodeHint: '您可以直接复制整个链接或仅复制 code 参数值,系统会自动识别',
// Refresh Token auth
refreshTokenAuth: '手动输入 RT',
refreshTokenDesc: '输入您已有的 OpenAI Refresh Token支持批量输入每行一个系统将自动验证并创建账号。',
refreshTokenPlaceholder: '粘贴您的 OpenAI Refresh Token...\n支持多个每行一个',
validating: '验证中...',
validateAndCreate: '验证并创建账号',
pleaseEnterRefreshToken: '请输入 Refresh Token'
},
// Gemini specific
gemini: {