diff --git a/src/assets/icons/vertex.svg b/src/assets/icons/vertex.svg new file mode 100644 index 0000000..efc3589 --- /dev/null +++ b/src/assets/icons/vertex.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 510e454..f7228c8 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -358,7 +358,7 @@ "models_excluded_hint": "This model is excluded by OAuth" }, "vertex_import": { - "title": "Vertex AI Credential Import", + "title": "Vertex JSON Login", "description": "Upload a Google service account JSON to store it as auth-dir/vertex-.json using the same rules as the CLI vertex-import helper.", "location_label": "Region (optional)", "location_placeholder": "us-central1", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index e8b449b..4740c2a 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -358,7 +358,7 @@ "models_excluded_hint": "此模型已被 OAuth 排除" }, "vertex_import": { - "title": "Vertex AI 凭证导入", + "title": "Vertex JSON 登录", "description": "上传 Google 服务账号 JSON,使用 CLI vertex-import 同步规则写入 auth-dir/vertex-.json。", "location_label": "目标区域 (可选)", "location_placeholder": "us-central1", diff --git a/src/pages/OAuthPage.module.scss b/src/pages/OAuthPage.module.scss index 1b73426..777ecd1 100644 --- a/src/pages/OAuthPage.module.scss +++ b/src/pages/OAuthPage.module.scss @@ -114,3 +114,25 @@ gap: $spacing-sm; margin-top: $spacing-sm; } + +.filePicker { + display: flex; + align-items: center; + gap: $spacing-sm; + flex-wrap: wrap; +} + +.fileName { + flex: 1; + min-width: 220px; + padding: 10px 12px; + border: 1px solid var(--border-color); + border-radius: $radius-md; + background: var(--bg-primary); + color: var(--text-primary); + font-size: 14px; +} + +.fileNamePlaceholder { + color: var(--text-secondary); +} diff --git a/src/pages/OAuthPage.tsx b/src/pages/OAuthPage.tsx index 2b4135d..172b823 100644 --- a/src/pages/OAuthPage.tsx +++ b/src/pages/OAuthPage.tsx @@ -1,10 +1,11 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, type ChangeEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { useNotificationStore, useThemeStore } from '@/stores'; import { oauthApi, type OAuthProvider, type IFlowCookieAuthResponse } from '@/services/api/oauth'; +import { vertexApi, type VertexImportResponse } from '@/services/api/vertex'; import styles from './OAuthPage.module.scss'; import iconOpenaiLight from '@/assets/icons/openai-light.svg'; import iconOpenaiDark from '@/assets/icons/openai-dark.svg'; @@ -13,6 +14,7 @@ import iconAntigravity from '@/assets/icons/antigravity.svg'; import iconGemini from '@/assets/icons/gemini.svg'; import iconQwen from '@/assets/icons/qwen.svg'; import iconIflow from '@/assets/icons/iflow.svg'; +import iconVertex from '@/assets/icons/vertex.svg'; interface ProviderState { url?: string; @@ -36,6 +38,22 @@ interface IFlowCookieState { errorType?: 'error' | 'warning'; } +interface VertexImportResult { + projectId?: string; + email?: string; + location?: string; + authFile?: string; +} + +interface VertexImportState { + file?: File; + fileName: string; + location: string; + loading: boolean; + error?: string; + result?: VertexImportResult; +} + const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabelKey: string; icon: string | { light: string; dark: string } }[] = [ { id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label', icon: { light: iconOpenaiLight, dark: iconOpenaiDark } }, { id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude }, @@ -57,7 +75,13 @@ export function OAuthPage() { const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const [states, setStates] = useState>({} as Record); const [iflowCookie, setIflowCookie] = useState({ cookie: '', loading: false }); + const [vertexState, setVertexState] = useState({ + fileName: '', + location: '', + loading: false + }); const timers = useRef>({}); + const vertexFileInputRef = useRef(null); useEffect(() => { return () => { @@ -216,6 +240,64 @@ export function OAuthPage() { } }; + const handleVertexFilePick = () => { + vertexFileInputRef.current?.click(); + }; + + const handleVertexFileChange = (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + if (!file.name.endsWith('.json')) { + showNotification(t('vertex_import.file_required'), 'warning'); + event.target.value = ''; + return; + } + setVertexState((prev) => ({ + ...prev, + file, + fileName: file.name, + error: undefined, + result: undefined + })); + event.target.value = ''; + }; + + const handleVertexImport = async () => { + if (!vertexState.file) { + const message = t('vertex_import.file_required'); + setVertexState((prev) => ({ ...prev, error: message })); + showNotification(message, 'warning'); + return; + } + const location = vertexState.location.trim(); + setVertexState((prev) => ({ ...prev, loading: true, error: undefined, result: undefined })); + try { + const res: VertexImportResponse = await vertexApi.importCredential( + vertexState.file, + location || undefined + ); + const result: VertexImportResult = { + projectId: res.project_id, + email: res.email, + location: res.location, + authFile: res['auth-file'] ?? res.auth_file + }; + setVertexState((prev) => ({ ...prev, loading: false, result })); + showNotification(t('vertex_import.success'), 'success'); + } catch (err: any) { + const message = err?.message || ''; + setVertexState((prev) => ({ + ...prev, + loading: false, + error: message || t('notification.upload_failed') + })); + const notification = message + ? `${t('notification.upload_failed')}: ${message}` + : t('notification.upload_failed'); + showNotification(notification, 'error'); + } + }; + return (

{t('nav.oauth', { defaultValue: 'OAuth' })}

@@ -328,6 +410,94 @@ export function OAuthPage() { ); })} + {/* Vertex JSON 登录 */} + + + {t('vertex_import.title')} + + } + extra={ + + } + > +
{t('vertex_import.description')}
+ + setVertexState((prev) => ({ + ...prev, + location: e.target.value + })) + } + placeholder={t('vertex_import.location_placeholder')} + /> +
+ +
+ +
+ {vertexState.fileName || t('vertex_import.file_placeholder')} +
+
+
{t('vertex_import.file_hint')}
+ +
+ {vertexState.error && ( +
+ {vertexState.error} +
+ )} + {vertexState.result && ( +
+
{t('vertex_import.result_title')}
+
+ {vertexState.result.projectId && ( +
+ {t('vertex_import.result_project')} + {vertexState.result.projectId} +
+ )} + {vertexState.result.email && ( +
+ {t('vertex_import.result_email')} + {vertexState.result.email} +
+ )} + {vertexState.result.location && ( +
+ {t('vertex_import.result_location')} + {vertexState.result.location} +
+ )} + {vertexState.result.authFile && ( +
+ {t('vertex_import.result_file')} + {vertexState.result.authFile} +
+ )} +
+
+ )} +
+ {/* iFlow Cookie 登录 */} { + const formData = new FormData(); + formData.append('file', file); + if (location) { + formData.append('location', location); + } + return apiClient.postForm('/vertex/import', formData); + } +};