Commit 1ac8b1f0 authored by ianshaw's avatar ianshaw
Browse files

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

- CreateAccountModal: 添加 Gemini 平台选项和 OAuth 授权流程
- EditAccountModal: 支持 Gemini 账号编辑
- OAuthAuthorizationFlow: 新增 Gemini 平台 OAuth 流程处理(支持 state 参数)
- ReAuthAccountModal: 支持 Gemini 账号重新授权
- 优化代码格式和组件逻辑
parent 0b30cc2b
...@@ -7,29 +7,55 @@ ...@@ -7,29 +7,55 @@
> >
<div v-if="account" class="space-y-5"> <div v-if="account" class="space-y-5">
<!-- Account Info --> <!-- 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 items-center gap-3">
<div :class="[ <div
'flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br', :class="[
isOpenAI ? 'from-green-500 to-green-600' : 'from-orange-500 to-orange-600' 'flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br',
]"> isOpenAI
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> ? 'from-green-500 to-green-600'
<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" /> : 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> </svg>
</div> </div>
<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"> <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> </span>
</div> </div>
</div> </div>
</div> </div>
<!-- Add Method Selection (Claude only) --> <!-- Add Method Selection (Claude only) -->
<div v-if="!isOpenAI"> <div v-if="isAnthropic">
<label class="input-label">{{ t('admin.accounts.oauth.authMethod') }}</label> <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"> <label class="flex cursor-pointer items-center">
<input <input
v-model="addMethod" v-model="addMethod"
...@@ -46,12 +72,42 @@ ...@@ -46,12 +72,42 @@
value="setup-token" value="setup-token"
class="mr-2 text-primary-600 focus:ring-primary-500" 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> </label>
</div> </div>
</div> </div>
<!-- OAuth Authorization Section --> <!-- 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 <OAuthAuthorizationFlow
ref="oauthFlowRef" ref="oauthFlowRef"
:add-method="addMethod" :add-method="addMethod"
...@@ -59,22 +115,18 @@ ...@@ -59,22 +115,18 @@
:session-id="currentSessionId" :session-id="currentSessionId"
:loading="currentLoading" :loading="currentLoading"
:error="currentError" :error="currentError"
:show-help="!isOpenAI" :show-help="isAnthropic"
:show-proxy-warning="!isOpenAI" :show-proxy-warning="isAnthropic"
:show-cookie-option="!isOpenAI" :show-cookie-option="isAnthropic"
:allow-multiple="false" :allow-multiple="false"
:method-label="t('admin.accounts.inputMethod')" :method-label="t('admin.accounts.inputMethod')"
:platform="isOpenAI ? 'openai' : 'anthropic'" :platform="isOpenAI ? 'openai' : isGemini ? 'gemini' : 'anthropic'"
@generate-url="handleGenerateUrl" @generate-url="handleGenerateUrl"
@cookie-auth="handleCookieAuth" @cookie-auth="handleCookieAuth"
/> />
<div class="flex justify-between gap-3 pt-4"> <div class="flex justify-between gap-3 pt-4">
<button <button type="button" class="btn btn-secondary" @click="handleClose">
type="button"
class="btn btn-secondary"
@click="handleClose"
>
{{ t('common.cancel') }} {{ t('common.cancel') }}
</button> </button>
<button <button
...@@ -86,14 +138,29 @@ ...@@ -86,14 +138,29 @@
> >
<svg <svg
v-if="currentLoading" 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" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <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> 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> </svg>
{{ currentLoading ? t('admin.accounts.oauth.verifying') : t('admin.accounts.oauth.completeAuth') }} {{
currentLoading
? t('admin.accounts.oauth.verifying')
: t('admin.accounts.oauth.completeAuth')
}}
</button> </button>
</div> </div>
</div> </div>
...@@ -105,8 +172,13 @@ import { ref, computed, watch } from 'vue' ...@@ -105,8 +172,13 @@ import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin' 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 { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
import type { Account } from '@/types' import type { Account } from '@/types'
import Modal from '@/components/common/Modal.vue' import Modal from '@/components/common/Modal.vue'
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue' import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
...@@ -115,6 +187,7 @@ 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 // Note: defineExpose automatically unwraps refs, so we use the unwrapped types
interface OAuthFlowExposed { interface OAuthFlowExposed {
authCode: string authCode: string
oauthState: string
sessionKey: string sessionKey: string
inputMethod: AuthInputMethod inputMethod: AuthInputMethod
reset: () => void reset: () => void
...@@ -137,6 +210,7 @@ const { t } = useI18n() ...@@ -137,6 +210,7 @@ const { t } = useI18n()
// OAuth composables - use both Claude and OpenAI // OAuth composables - use both Claude and OpenAI
const claudeOAuth = useAccountOAuth() const claudeOAuth = useAccountOAuth()
const openaiOAuth = useOpenAIOAuth() const openaiOAuth = useOpenAIOAuth()
const geminiOAuth = useGeminiOAuth()
// Refs // Refs
const oauthFlowRef = ref<OAuthFlowExposed | null>(null) const oauthFlowRef = ref<OAuthFlowExposed | null>(null)
...@@ -146,43 +220,87 @@ const addMethod = ref<AddMethod>('oauth') ...@@ -146,43 +220,87 @@ const addMethod = ref<AddMethod>('oauth')
// Computed - check if this is an OpenAI account // Computed - check if this is an OpenAI account
const isOpenAI = computed(() => props.account?.platform === 'openai') 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 // Computed - current OAuth state based on platform
const currentAuthUrl = computed(() => isOpenAI.value ? openaiOAuth.authUrl.value : claudeOAuth.authUrl.value) const currentAuthUrl = computed(() => {
const currentSessionId = computed(() => isOpenAI.value ? openaiOAuth.sessionId.value : claudeOAuth.sessionId.value) if (isOpenAI.value) return openaiOAuth.authUrl.value
const currentLoading = computed(() => isOpenAI.value ? openaiOAuth.loading.value : claudeOAuth.loading.value) if (isGemini.value) return geminiOAuth.authUrl.value
const currentError = computed(() => isOpenAI.value ? openaiOAuth.error.value : claudeOAuth.error.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 // Computed
const isManualInputMethod = computed(() => { const isManualInputMethod = computed(() => {
// OpenAI always uses manual input (no cookie auth option) // 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 canExchangeCode = computed(() => {
const authCode = oauthFlowRef.value?.authCode || '' const authCode = oauthFlowRef.value?.authCode || ''
const sessionId = isOpenAI.value ? openaiOAuth.sessionId.value : claudeOAuth.sessionId.value const sessionId = isOpenAI.value
const loading = isOpenAI.value ? openaiOAuth.loading.value : claudeOAuth.loading.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 return authCode.trim() && sessionId && !loading
}) })
// Watchers // Watchers
watch(() => props.show, (newVal) => { watch(
if (newVal && props.account) { () => props.show,
// Initialize addMethod based on current account type (Claude only) (newVal) => {
if (!isOpenAI.value && (props.account.type === 'oauth' || props.account.type === 'setup-token')) { if (newVal && props.account) {
addMethod.value = props.account.type as AddMethod // 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 // Methods
const resetState = () => { const resetState = () => {
addMethod.value = 'oauth' addMethod.value = 'oauth'
claudeOAuth.resetState() claudeOAuth.resetState()
openaiOAuth.resetState() openaiOAuth.resetState()
geminiOAuth.resetState()
geminiRedirectUri.value = defaultCallbackUrl
geminiRedirectUriConfirmed.value = false
oauthFlowRef.value?.reset() oauthFlowRef.value?.reset()
} }
...@@ -195,6 +313,16 @@ const handleGenerateUrl = async () => { ...@@ -195,6 +313,16 @@ const handleGenerateUrl = async () => {
if (isOpenAI.value) { if (isOpenAI.value) {
await openaiOAuth.generateAuthUrl(props.account.proxy_id) 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 { } else {
await claudeOAuth.generateAuthUrl(addMethod.value, props.account.proxy_id) await claudeOAuth.generateAuthUrl(addMethod.value, props.account.proxy_id)
} }
...@@ -211,7 +339,11 @@ const handleExchangeCode = async () => { ...@@ -211,7 +339,11 @@ const handleExchangeCode = async () => {
const sessionId = openaiOAuth.sessionId.value const sessionId = openaiOAuth.sessionId.value
if (!sessionId) return 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 if (!tokenInfo) return
// Build credentials and extra info // Build credentials and extra info
...@@ -236,6 +368,49 @@ const handleExchangeCode = async () => { ...@@ -236,6 +368,49 @@ const handleExchangeCode = async () => {
openaiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed') openaiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(openaiOAuth.error.value) 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 { } else {
// Claude OAuth flow // Claude OAuth flow
const sessionId = claudeOAuth.sessionId.value const sessionId = claudeOAuth.sessionId.value
...@@ -246,9 +421,10 @@ const handleExchangeCode = async () => { ...@@ -246,9 +421,10 @@ const handleExchangeCode = async () => {
try { try {
const proxyConfig = props.account.proxy_id ? { proxy_id: props.account.proxy_id } : {} const proxyConfig = props.account.proxy_id ? { proxy_id: props.account.proxy_id } : {}
const endpoint = addMethod.value === 'oauth' const endpoint =
? '/admin/accounts/exchange-code' addMethod.value === 'oauth'
: '/admin/accounts/exchange-setup-token-code' ? '/admin/accounts/exchange-code'
: '/admin/accounts/exchange-setup-token-code'
const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, { const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, {
session_id: sessionId, session_id: sessionId,
...@@ -280,6 +456,28 @@ const handleExchangeCode = async () => { ...@@ -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) => { const handleCookieAuth = async (sessionKey: string) => {
if (!props.account || isOpenAI.value) return if (!props.account || isOpenAI.value) return
...@@ -288,9 +486,10 @@ const handleCookieAuth = async (sessionKey: string) => { ...@@ -288,9 +486,10 @@ const handleCookieAuth = async (sessionKey: string) => {
try { try {
const proxyConfig = props.account.proxy_id ? { proxy_id: props.account.proxy_id } : {} const proxyConfig = props.account.proxy_id ? { proxy_id: props.account.proxy_id } : {}
const endpoint = addMethod.value === 'oauth' const endpoint =
? '/admin/accounts/cookie-auth' addMethod.value === 'oauth'
: '/admin/accounts/setup-token-cookie-auth' ? '/admin/accounts/cookie-auth'
: '/admin/accounts/setup-token-cookie-auth'
const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, { const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, {
session_id: '', session_id: '',
...@@ -314,7 +513,8 @@ const handleCookieAuth = async (sessionKey: string) => { ...@@ -314,7 +513,8 @@ const handleCookieAuth = async (sessionKey: string) => {
emit('reauthorized') emit('reauthorized')
handleClose() handleClose()
} catch (error: any) { } 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 { } finally {
claudeOAuth.loading.value = false claudeOAuth.loading.value = false
} }
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment