2025-12-25 08:39:48 -08:00
|
|
|
import { ref } from 'vue'
|
|
|
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
|
import { useAppStore } from '@/stores/app'
|
|
|
|
|
import { adminAPI } from '@/api/admin'
|
2025-12-25 23:52:26 -08:00
|
|
|
import type { GeminiOAuthCapabilities } from '@/api/admin/gemini'
|
2025-12-25 08:39:48 -08:00
|
|
|
|
|
|
|
|
export interface GeminiTokenInfo {
|
|
|
|
|
access_token?: string
|
|
|
|
|
refresh_token?: string
|
|
|
|
|
token_type?: string
|
|
|
|
|
scope?: string
|
|
|
|
|
expires_at?: number | string
|
|
|
|
|
project_id?: string
|
2025-12-25 21:25:02 -08:00
|
|
|
oauth_type?: string
|
2026-01-03 06:32:04 -08:00
|
|
|
tier_id?: string
|
|
|
|
|
extra?: Record<string, unknown>
|
2025-12-25 08:39:48 -08:00
|
|
|
[key: string]: unknown
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function useGeminiOAuth() {
|
|
|
|
|
const appStore = useAppStore()
|
|
|
|
|
const { t } = useI18n()
|
|
|
|
|
|
|
|
|
|
const authUrl = ref('')
|
|
|
|
|
const sessionId = ref('')
|
|
|
|
|
const state = ref('')
|
|
|
|
|
const loading = ref(false)
|
|
|
|
|
const error = ref('')
|
|
|
|
|
|
|
|
|
|
const resetState = () => {
|
|
|
|
|
authUrl.value = ''
|
|
|
|
|
sessionId.value = ''
|
|
|
|
|
state.value = ''
|
|
|
|
|
loading.value = false
|
|
|
|
|
error.value = ''
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const generateAuthUrl = async (
|
|
|
|
|
proxyId: number | null | undefined,
|
2025-12-25 21:25:02 -08:00
|
|
|
projectId?: string | null,
|
2026-01-04 15:36:00 +08:00
|
|
|
oauthType?: string,
|
|
|
|
|
tierId?: string
|
2025-12-25 08:39:48 -08:00
|
|
|
): Promise<boolean> => {
|
|
|
|
|
loading.value = true
|
|
|
|
|
authUrl.value = ''
|
|
|
|
|
sessionId.value = ''
|
|
|
|
|
state.value = ''
|
|
|
|
|
error.value = ''
|
|
|
|
|
|
|
|
|
|
try {
|
2025-12-25 21:25:02 -08:00
|
|
|
const payload: Record<string, unknown> = {}
|
2025-12-25 08:39:48 -08:00
|
|
|
if (proxyId) payload.proxy_id = proxyId
|
2025-12-25 21:25:02 -08:00
|
|
|
const trimmedProjectID = projectId?.trim()
|
|
|
|
|
if (trimmedProjectID) payload.project_id = trimmedProjectID
|
|
|
|
|
if (oauthType) payload.oauth_type = oauthType
|
2026-01-04 15:36:00 +08:00
|
|
|
const trimmedTierID = tierId?.trim()
|
|
|
|
|
if (trimmedTierID) payload.tier_id = trimmedTierID
|
2025-12-25 08:39:48 -08:00
|
|
|
|
|
|
|
|
const response = await adminAPI.gemini.generateAuthUrl(payload as any)
|
|
|
|
|
authUrl.value = response.auth_url
|
|
|
|
|
sessionId.value = response.session_id
|
|
|
|
|
state.value = response.state
|
|
|
|
|
return true
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
error.value = err.response?.data?.detail || t('admin.accounts.oauth.gemini.failedToGenerateUrl')
|
|
|
|
|
appStore.showError(error.value)
|
|
|
|
|
return false
|
|
|
|
|
} finally {
|
|
|
|
|
loading.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const exchangeAuthCode = async (params: {
|
|
|
|
|
code: string
|
|
|
|
|
sessionId: string
|
|
|
|
|
state: string
|
|
|
|
|
proxyId?: number | null
|
2025-12-25 21:25:02 -08:00
|
|
|
oauthType?: string
|
2026-01-04 15:36:00 +08:00
|
|
|
tierId?: string
|
2025-12-25 08:39:48 -08:00
|
|
|
}): Promise<GeminiTokenInfo | null> => {
|
|
|
|
|
const code = params.code?.trim()
|
2025-12-25 21:25:02 -08:00
|
|
|
if (!code || !params.sessionId || !params.state) {
|
2025-12-25 08:39:48 -08:00
|
|
|
error.value = t('admin.accounts.oauth.gemini.missingExchangeParams')
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
loading.value = true
|
|
|
|
|
error.value = ''
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const payload: Record<string, unknown> = {
|
|
|
|
|
session_id: params.sessionId,
|
|
|
|
|
state: params.state,
|
2025-12-25 21:25:02 -08:00
|
|
|
code
|
2025-12-25 08:39:48 -08:00
|
|
|
}
|
|
|
|
|
if (params.proxyId) payload.proxy_id = params.proxyId
|
2025-12-25 21:25:02 -08:00
|
|
|
if (params.oauthType) payload.oauth_type = params.oauthType
|
2026-01-04 15:36:00 +08:00
|
|
|
const trimmedTierID = params.tierId?.trim()
|
|
|
|
|
if (trimmedTierID) payload.tier_id = trimmedTierID
|
2025-12-25 08:39:48 -08:00
|
|
|
|
|
|
|
|
const tokenInfo = await adminAPI.gemini.exchangeCode(payload as any)
|
|
|
|
|
return tokenInfo as GeminiTokenInfo
|
|
|
|
|
} catch (err: any) {
|
feat(gemini): 添加 Google One 存储空间推断 Tier 功能
## 功能概述
通过 Google Drive API 获取存储空间配额来推断 Google One 订阅等级,并优化统一的配额显示系统。
## 后端改动
- 新增 Drive API 客户端 (drive_client.go)
- 支持代理和指数退避重试
- 处理 403/429 错误
- 添加 Tier 推断逻辑 (inferGoogleOneTier)
- 支持 6 种 tier 类型:AI_PREMIUM, GOOGLE_ONE_STANDARD, GOOGLE_ONE_BASIC, FREE, GOOGLE_ONE_UNKNOWN, GOOGLE_ONE_UNLIMITED
- 集成到 OAuth 流程
- ExchangeCode: 授权时自动获取 tier
- RefreshAccountToken: Token 刷新时更新 tier (24小时缓存)
- 新增管理 API 端点
- POST /api/v1/admin/accounts/:id/refresh-tier (单个账号刷新)
- POST /api/v1/admin/accounts/batch-refresh-tier (批量刷新)
## 前端改动
- 更新 AccountQuotaInfo.vue
- 添加 Google One tier 标签映射
- 添加 tier 颜色样式 (紫色/蓝色/绿色/灰色/琥珀色)
- 更新 AccountUsageCell.vue
- 添加 Google One tier 显示逻辑
- 根据 oauth_type 区分显示方式
- 添加国际化翻译 (en.ts, zh.ts)
- aiPremium, standard, basic, free, personal, unlimited
## Tier 推断规则
- >= 2TB: AI Premium
- >= 200GB: Google One Standard
- >= 100GB: Google One Basic
- >= 15GB: Free
- > 100TB: Unlimited (G Suite legacy)
- 其他/失败: Unknown (显示为 Personal)
## 优雅降级
- Drive API 失败时使用 GOOGLE_ONE_UNKNOWN
- 不阻断 OAuth 流程
- 24小时缓存避免频繁调用
## 测试
- ✅ 后端编译成功
- ✅ 前端构建成功
- ✅ 所有代码符合现有规范
2025-12-31 21:45:24 -08:00
|
|
|
// Check for specific missing project_id error
|
|
|
|
|
const errorMessage = err.message || err.response?.data?.message || ''
|
|
|
|
|
if (errorMessage.includes('missing project_id')) {
|
|
|
|
|
error.value = t('admin.accounts.oauth.gemini.missingProjectId')
|
|
|
|
|
} else {
|
|
|
|
|
error.value = errorMessage || t('admin.accounts.oauth.gemini.failedToExchangeCode')
|
|
|
|
|
}
|
2025-12-25 08:39:48 -08:00
|
|
|
appStore.showError(error.value)
|
|
|
|
|
return null
|
|
|
|
|
} finally {
|
|
|
|
|
loading.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const buildCredentials = (tokenInfo: GeminiTokenInfo): Record<string, unknown> => {
|
|
|
|
|
let expiresAt: string | undefined
|
|
|
|
|
if (typeof tokenInfo.expires_at === 'number' && Number.isFinite(tokenInfo.expires_at)) {
|
|
|
|
|
expiresAt = Math.floor(tokenInfo.expires_at).toString()
|
|
|
|
|
} else if (typeof tokenInfo.expires_at === 'string' && tokenInfo.expires_at.trim()) {
|
|
|
|
|
expiresAt = tokenInfo.expires_at.trim()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
access_token: tokenInfo.access_token,
|
|
|
|
|
refresh_token: tokenInfo.refresh_token,
|
|
|
|
|
token_type: tokenInfo.token_type,
|
|
|
|
|
expires_at: expiresAt,
|
|
|
|
|
scope: tokenInfo.scope,
|
2025-12-25 21:25:02 -08:00
|
|
|
project_id: tokenInfo.project_id,
|
2026-01-03 06:32:04 -08:00
|
|
|
oauth_type: tokenInfo.oauth_type,
|
|
|
|
|
tier_id: tokenInfo.tier_id
|
2025-12-25 08:39:48 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-03 06:32:04 -08:00
|
|
|
const buildExtraInfo = (tokenInfo: GeminiTokenInfo): Record<string, unknown> | undefined => {
|
|
|
|
|
if (!tokenInfo.extra || typeof tokenInfo.extra !== 'object') return undefined
|
|
|
|
|
return tokenInfo.extra
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-25 23:52:26 -08:00
|
|
|
const getCapabilities = async (): Promise<GeminiOAuthCapabilities | null> => {
|
|
|
|
|
try {
|
|
|
|
|
return await adminAPI.gemini.getCapabilities()
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
// Capabilities are optional for older servers; don't block the UI.
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-25 08:39:48 -08:00
|
|
|
return {
|
|
|
|
|
authUrl,
|
|
|
|
|
sessionId,
|
|
|
|
|
state,
|
|
|
|
|
loading,
|
|
|
|
|
error,
|
|
|
|
|
resetState,
|
|
|
|
|
generateAuthUrl,
|
|
|
|
|
exchangeAuthCode,
|
2025-12-25 23:52:26 -08:00
|
|
|
buildCredentials,
|
2026-01-03 06:32:04 -08:00
|
|
|
buildExtraInfo,
|
2025-12-25 23:52:26 -08:00
|
|
|
getCapabilities
|
2025-12-25 08:39:48 -08:00
|
|
|
}
|
|
|
|
|
}
|