feat(frontend): Components 集成 Gemini 账号支持

- CreateAccountModal: 添加 Gemini 平台选项和 OAuth 授权流程
- EditAccountModal: 支持 Gemini 账号编辑
- OAuthAuthorizationFlow: 新增 Gemini 平台 OAuth 流程处理(支持 state 参数)
- ReAuthAccountModal: 支持 Gemini 账号重新授权
- 优化代码格式和组件逻辑
This commit is contained in:
ianshaw
2025-12-25 08:40:05 -08:00
parent 0b30cc2b7e
commit 1ac8b1f03e
4 changed files with 1783 additions and 510 deletions

View File

@@ -7,29 +7,55 @@
>
<div v-if="account" class="space-y-5">
<!-- Account Info -->
<div class="rounded-lg border border-gray-200 dark:border-dark-600 bg-gray-50 dark:bg-dark-700 p-4">
<div
class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-700"
>
<div class="flex items-center gap-3">
<div :class="[
'flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br',
isOpenAI ? 'from-green-500 to-green-600' : 'from-orange-500 to-orange-600'
]">
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
<div
:class="[
'flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br',
isOpenAI
? 'from-green-500 to-green-600'
: isGemini
? 'from-blue-500 to-blue-600'
: 'from-orange-500 to-orange-600'
]"
>
<svg
class="h-5 w-5 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
/>
</svg>
</div>
<div>
<span class="block font-semibold text-gray-900 dark:text-white">{{ account.name }}</span>
<span class="block font-semibold text-gray-900 dark:text-white">{{
account.name
}}</span>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ isOpenAI ? t('admin.accounts.openaiAccount') : t('admin.accounts.claudeCodeAccount') }}
{{
isOpenAI
? t('admin.accounts.openaiAccount')
: isGemini
? t('admin.accounts.geminiAccount')
: t('admin.accounts.claudeCodeAccount')
}}
</span>
</div>
</div>
</div>
<!-- Add Method Selection (Claude only) -->
<div v-if="!isOpenAI">
<div v-if="isAnthropic">
<label class="input-label">{{ t('admin.accounts.oauth.authMethod') }}</label>
<div class="flex gap-4 mt-2">
<div class="mt-2 flex gap-4">
<label class="flex cursor-pointer items-center">
<input
v-model="addMethod"
@@ -46,12 +72,42 @@
value="setup-token"
class="mr-2 text-primary-600 focus:ring-primary-500"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.accounts.setupTokenLongLived') }}</span>
<span class="text-sm text-gray-700 dark:text-gray-300">{{
t('admin.accounts.setupTokenLongLived')
}}</span>
</label>
</div>
</div>
<!-- OAuth Authorization Section -->
<div
v-if="isGemini"
class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-700"
>
<label class="input-label">{{ t('admin.accounts.oauth.gemini.redirectUri') }}</label>
<input
v-model="geminiRedirectUri"
type="text"
class="input font-mono text-sm"
:placeholder="defaultCallbackUrl"
/>
<p class="input-hint">{{ t('admin.accounts.oauth.gemini.redirectUriHint') }}</p>
<div class="mt-3 flex items-start gap-2">
<input
id="gemini-redirect-uri-confirm"
v-model="geminiRedirectUriConfirmed"
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-500"
/>
<label for="gemini-redirect-uri-confirm" class="text-sm text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.oauth.gemini.confirmRedirectUri') }}
</label>
</div>
<p v-if="geminiRedirectUri && !isValidGeminiRedirectUri" class="mt-2 text-xs text-red-600">
{{ t('admin.accounts.oauth.gemini.invalidRedirectUri') }}
</p>
</div>
<OAuthAuthorizationFlow
ref="oauthFlowRef"
:add-method="addMethod"
@@ -59,22 +115,18 @@
:session-id="currentSessionId"
:loading="currentLoading"
:error="currentError"
:show-help="!isOpenAI"
:show-proxy-warning="!isOpenAI"
:show-cookie-option="!isOpenAI"
:show-help="isAnthropic"
:show-proxy-warning="isAnthropic"
:show-cookie-option="isAnthropic"
:allow-multiple="false"
:method-label="t('admin.accounts.inputMethod')"
:platform="isOpenAI ? 'openai' : 'anthropic'"
:platform="isOpenAI ? 'openai' : isGemini ? 'gemini' : 'anthropic'"
@generate-url="handleGenerateUrl"
@cookie-auth="handleCookieAuth"
/>
<div class="flex justify-between gap-3 pt-4">
<button
type="button"
class="btn btn-secondary"
@click="handleClose"
>
<button type="button" class="btn btn-secondary" @click="handleClose">
{{ t('common.cancel') }}
</button>
<button
@@ -86,14 +138,29 @@
>
<svg
v-if="currentLoading"
class="animate-spin -ml-1 mr-2 h-4 w-4"
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>
<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>
{{ currentLoading ? t('admin.accounts.oauth.verifying') : t('admin.accounts.oauth.completeAuth') }}
{{
currentLoading
? t('admin.accounts.oauth.verifying')
: t('admin.accounts.oauth.completeAuth')
}}
</button>
</div>
</div>
@@ -105,8 +172,13 @@ import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import { useAccountOAuth, type AddMethod, type AuthInputMethod } from '@/composables/useAccountOAuth'
import {
useAccountOAuth,
type AddMethod,
type AuthInputMethod
} from '@/composables/useAccountOAuth'
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
import type { Account } from '@/types'
import Modal from '@/components/common/Modal.vue'
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
@@ -115,6 +187,7 @@ import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
// Note: defineExpose automatically unwraps refs, so we use the unwrapped types
interface OAuthFlowExposed {
authCode: string
oauthState: string
sessionKey: string
inputMethod: AuthInputMethod
reset: () => void
@@ -137,6 +210,7 @@ const { t } = useI18n()
// OAuth composables - use both Claude and OpenAI
const claudeOAuth = useAccountOAuth()
const openaiOAuth = useOpenAIOAuth()
const geminiOAuth = useGeminiOAuth()
// Refs
const oauthFlowRef = ref<OAuthFlowExposed | null>(null)
@@ -146,43 +220,87 @@ const addMethod = ref<AddMethod>('oauth')
// Computed - check if this is an OpenAI account
const isOpenAI = computed(() => props.account?.platform === 'openai')
const isGemini = computed(() => props.account?.platform === 'gemini')
const isAnthropic = computed(() => props.account?.platform === 'anthropic')
// Computed - current OAuth state based on platform
const currentAuthUrl = computed(() => isOpenAI.value ? openaiOAuth.authUrl.value : claudeOAuth.authUrl.value)
const currentSessionId = computed(() => isOpenAI.value ? openaiOAuth.sessionId.value : claudeOAuth.sessionId.value)
const currentLoading = computed(() => isOpenAI.value ? openaiOAuth.loading.value : claudeOAuth.loading.value)
const currentError = computed(() => isOpenAI.value ? openaiOAuth.error.value : claudeOAuth.error.value)
const currentAuthUrl = computed(() => {
if (isOpenAI.value) return openaiOAuth.authUrl.value
if (isGemini.value) return geminiOAuth.authUrl.value
return claudeOAuth.authUrl.value
})
const currentSessionId = computed(() => {
if (isOpenAI.value) return openaiOAuth.sessionId.value
if (isGemini.value) return geminiOAuth.sessionId.value
return claudeOAuth.sessionId.value
})
const currentLoading = computed(() => {
if (isOpenAI.value) return openaiOAuth.loading.value
if (isGemini.value) return geminiOAuth.loading.value
return claudeOAuth.loading.value
})
const currentError = computed(() => {
if (isOpenAI.value) return openaiOAuth.error.value
if (isGemini.value) return geminiOAuth.error.value
return claudeOAuth.error.value
})
// Computed
const isManualInputMethod = computed(() => {
// OpenAI always uses manual input (no cookie auth option)
return isOpenAI.value || oauthFlowRef.value?.inputMethod === 'manual'
return isOpenAI.value || isGemini.value || oauthFlowRef.value?.inputMethod === 'manual'
})
const canExchangeCode = computed(() => {
const authCode = oauthFlowRef.value?.authCode || ''
const sessionId = isOpenAI.value ? openaiOAuth.sessionId.value : claudeOAuth.sessionId.value
const loading = isOpenAI.value ? openaiOAuth.loading.value : claudeOAuth.loading.value
const sessionId = isOpenAI.value
? openaiOAuth.sessionId.value
: isGemini.value
? geminiOAuth.sessionId.value
: claudeOAuth.sessionId.value
const loading = isOpenAI.value
? openaiOAuth.loading.value
: isGemini.value
? geminiOAuth.loading.value
: claudeOAuth.loading.value
if (isGemini.value) {
return (
authCode.trim() &&
sessionId &&
!loading &&
geminiRedirectUriConfirmed.value &&
isValidGeminiRedirectUri.value
)
}
return authCode.trim() && sessionId && !loading
})
// Watchers
watch(() => props.show, (newVal) => {
if (newVal && props.account) {
// Initialize addMethod based on current account type (Claude only)
if (!isOpenAI.value && (props.account.type === 'oauth' || props.account.type === 'setup-token')) {
addMethod.value = props.account.type as AddMethod
watch(
() => props.show,
(newVal) => {
if (newVal && props.account) {
// Initialize addMethod based on current account type (Claude only)
if (
isAnthropic.value &&
(props.account.type === 'oauth' || props.account.type === 'setup-token')
) {
addMethod.value = props.account.type as AddMethod
}
} else {
resetState()
}
} else {
resetState()
}
})
)
// Methods
const resetState = () => {
addMethod.value = 'oauth'
claudeOAuth.resetState()
openaiOAuth.resetState()
geminiOAuth.resetState()
geminiRedirectUri.value = defaultCallbackUrl
geminiRedirectUriConfirmed.value = false
oauthFlowRef.value?.reset()
}
@@ -195,6 +313,16 @@ const handleGenerateUrl = async () => {
if (isOpenAI.value) {
await openaiOAuth.generateAuthUrl(props.account.proxy_id)
} else if (isGemini.value) {
if (!isValidGeminiRedirectUri.value) {
appStore.showError(t('admin.accounts.oauth.gemini.invalidRedirectUri'))
return
}
if (!geminiRedirectUriConfirmed.value) {
appStore.showError(t('admin.accounts.oauth.gemini.redirectUriNotConfirmed'))
return
}
await geminiOAuth.generateAuthUrl(props.account.proxy_id, geminiRedirectUri.value)
} else {
await claudeOAuth.generateAuthUrl(addMethod.value, props.account.proxy_id)
}
@@ -211,7 +339,11 @@ const handleExchangeCode = async () => {
const sessionId = openaiOAuth.sessionId.value
if (!sessionId) return
const tokenInfo = await openaiOAuth.exchangeAuthCode(authCode.trim(), sessionId, props.account.proxy_id)
const tokenInfo = await openaiOAuth.exchangeAuthCode(
authCode.trim(),
sessionId,
props.account.proxy_id
)
if (!tokenInfo) return
// Build credentials and extra info
@@ -236,6 +368,49 @@ const handleExchangeCode = async () => {
openaiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(openaiOAuth.error.value)
}
} else if (isGemini.value) {
const sessionId = geminiOAuth.sessionId.value
if (!sessionId) return
if (!isValidGeminiRedirectUri.value) {
geminiOAuth.error.value = t('admin.accounts.oauth.gemini.invalidRedirectUri')
appStore.showError(geminiOAuth.error.value)
return
}
if (!geminiRedirectUriConfirmed.value) {
geminiOAuth.error.value = t('admin.accounts.oauth.gemini.redirectUriNotConfirmed')
appStore.showError(geminiOAuth.error.value)
return
}
const stateFromInput = oauthFlowRef.value?.oauthState || ''
const stateToUse = stateFromInput || geminiOAuth.state.value
if (!stateToUse) return
const tokenInfo = await geminiOAuth.exchangeAuthCode({
code: authCode.trim(),
sessionId,
state: stateToUse,
redirectUri: geminiRedirectUri.value,
proxyId: props.account.proxy_id
})
if (!tokenInfo) return
const credentials = geminiOAuth.buildCredentials(tokenInfo)
try {
await adminAPI.accounts.update(props.account.id, {
type: 'oauth',
credentials
})
await adminAPI.accounts.clearError(props.account.id)
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
emit('reauthorized')
handleClose()
} catch (error: any) {
geminiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(geminiOAuth.error.value)
}
} else {
// Claude OAuth flow
const sessionId = claudeOAuth.sessionId.value
@@ -246,9 +421,10 @@ const handleExchangeCode = async () => {
try {
const proxyConfig = props.account.proxy_id ? { proxy_id: props.account.proxy_id } : {}
const endpoint = addMethod.value === 'oauth'
? '/admin/accounts/exchange-code'
: '/admin/accounts/exchange-setup-token-code'
const endpoint =
addMethod.value === 'oauth'
? '/admin/accounts/exchange-code'
: '/admin/accounts/exchange-setup-token-code'
const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, {
session_id: sessionId,
@@ -280,6 +456,28 @@ const handleExchangeCode = async () => {
}
}
// 优先使用环境变量配置的 Redirect URI,否则使用当前域名
const defaultCallbackUrl =
import.meta.env.VITE_OAUTH_CALLBACK_URL ||
(typeof window !== 'undefined' ? `${window.location.origin}/auth/callback` : '')
const geminiRedirectUri = ref(defaultCallbackUrl)
const geminiRedirectUriConfirmed = ref(false)
const isValidGeminiRedirectUri = computed(() => {
const raw = geminiRedirectUri.value?.trim()
if (!raw) return false
try {
const parsed = new URL(raw)
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
} catch {
return false
}
})
watch(geminiRedirectUri, () => {
geminiRedirectUriConfirmed.value = false
})
const handleCookieAuth = async (sessionKey: string) => {
if (!props.account || isOpenAI.value) return
@@ -288,9 +486,10 @@ const handleCookieAuth = async (sessionKey: string) => {
try {
const proxyConfig = props.account.proxy_id ? { proxy_id: props.account.proxy_id } : {}
const endpoint = addMethod.value === 'oauth'
? '/admin/accounts/cookie-auth'
: '/admin/accounts/setup-token-cookie-auth'
const endpoint =
addMethod.value === 'oauth'
? '/admin/accounts/cookie-auth'
: '/admin/accounts/setup-token-cookie-auth'
const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, {
session_id: '',
@@ -314,7 +513,8 @@ const handleCookieAuth = async (sessionKey: string) => {
emit('reauthorized')
handleClose()
} catch (error: any) {
claudeOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.cookieAuthFailed')
claudeOAuth.error.value =
error.response?.data?.detail || t('admin.accounts.oauth.cookieAuthFailed')
} finally {
claudeOAuth.loading.value = false
}