feat: add recommended provider section and update Kimi login text in OAuth page

This commit is contained in:
Supra4E8C
2026-07-15 03:39:36 +08:00
parent 7fb5890413
commit e2aa49481e
6 changed files with 366 additions and 216 deletions
+6 -2
View File
@@ -563,6 +563,10 @@
"upgrade_required_desc": "The current server does not support the OAuth model aliases API. Please upgrade to the latest CLI Proxy API (CPA) version."
},
"auth_login": {
"recommended_provider_section": "Recommended provider",
"recommended_provider_badge": "Recommended provider",
"other_oauth_providers": "Other OAuth providers",
"other_login_methods": "Other login methods",
"codex_oauth_title": "Codex OAuth",
"codex_oauth_button": "Start Codex Login",
"codex_oauth_hint": "Login to Codex service through OAuth flow, automatically obtain and save authentication files.",
@@ -597,8 +601,8 @@
"antigravity_oauth_start_error": "Failed to start Antigravity OAuth:",
"antigravity_oauth_polling_error": "Failed to check authentication status:",
"kimi_oauth_title": "Kimi OAuth",
"kimi_oauth_button": "Start Kimi Login",
"kimi_oauth_hint": "Login to Kimi service through OAuth device flow, automatically obtain and save authentication files.",
"kimi_oauth_button": "Log in with Kimi",
"kimi_oauth_hint": "Quickly log in to Kimi through device authorization. Authentication files are obtained and saved automatically.",
"kimi_oauth_url_label": "Authorization URL:",
"kimi_open_link": "Open Link",
"kimi_copy_link": "Copy Link",
+6 -2
View File
@@ -550,6 +550,10 @@
"upgrade_required_desc": "Текущая версия сервера не поддерживает API псевдонимов моделей OAuth. Обновите CLI Proxy API (CPA) до последней версии."
},
"auth_login": {
"recommended_provider_section": "Рекомендуемый провайдер",
"recommended_provider_badge": "Рекомендуемый провайдер",
"other_oauth_providers": "Другие провайдеры OAuth",
"other_login_methods": "Другие способы входа",
"codex_oauth_title": "Codex OAuth",
"codex_oauth_button": "Начать вход Codex",
"codex_oauth_hint": "Выполните вход в сервис Codex через OAuth и автоматически получите/сохраните файлы авторизации.",
@@ -584,8 +588,8 @@
"antigravity_oauth_start_error": "Не удалось запустить Antigravity OAuth:",
"antigravity_oauth_polling_error": "Не удалось проверить статус аутентификации:",
"kimi_oauth_title": "Kimi OAuth",
"kimi_oauth_button": "Начать вход Kimi",
"kimi_oauth_hint": "Выполните вход в сервис Kimi через поток авторизации устройства и автоматически получите/сохраните файлы авторизации.",
"kimi_oauth_button": "Войти через Kimi",
"kimi_oauth_hint": "Быстро войдите в Kimi через авторизацию устройства. Файлы авторизации будут получены и сохранены автоматически.",
"kimi_oauth_url_label": "URL авторизации:",
"kimi_open_link": "Открыть ссылку",
"kimi_copy_link": "Скопировать ссылку",
+6 -2
View File
@@ -563,6 +563,10 @@
"upgrade_required_desc": "当前服务器版本不支持 OAuth 模型别名功能,请升级到最新版本的 CPA(CLI Proxy API)后重试。"
},
"auth_login": {
"recommended_provider_section": "推荐提供商",
"recommended_provider_badge": "推荐提供商",
"other_oauth_providers": "其他 OAuth 提供商",
"other_login_methods": "其他登录方式",
"codex_oauth_title": "Codex OAuth",
"codex_oauth_button": "开始 Codex 登录",
"codex_oauth_hint": "通过 OAuth 流程登录 Codex 服务,自动获取并保存认证文件。",
@@ -597,8 +601,8 @@
"antigravity_oauth_start_error": "启动 Antigravity OAuth 失败:",
"antigravity_oauth_polling_error": "检查认证状态失败:",
"kimi_oauth_title": "Kimi OAuth",
"kimi_oauth_button": "开始 Kimi 登录",
"kimi_oauth_hint": "通过设备授权流程登录 Kimi 服务,自动获取并保存认证文件。",
"kimi_oauth_button": "使用 Kimi 登录",
"kimi_oauth_hint": "通过设备授权快速登录 Kimi完成后将自动获取并保存认证文件。",
"kimi_oauth_url_label": "授权链接:",
"kimi_open_link": "打开链接",
"kimi_copy_link": "复制链接",
+6 -2
View File
@@ -563,6 +563,10 @@
"upgrade_required_desc": "目前伺服器版本不支援 OAuth 模型別名功能,請升級到最新版本的 CPA(CLI Proxy API)後重試。"
},
"auth_login": {
"recommended_provider_section": "推薦提供商",
"recommended_provider_badge": "推薦提供商",
"other_oauth_providers": "其他 OAuth 提供商",
"other_login_methods": "其他登入方式",
"codex_oauth_title": "Codex OAuth",
"codex_oauth_button": "開始 Codex 登入",
"codex_oauth_hint": "透過 OAuth 流程登入 Codex 服務,自動取得並儲存驗證檔案。",
@@ -597,8 +601,8 @@
"antigravity_oauth_start_error": "啟動 Antigravity OAuth 失敗:",
"antigravity_oauth_polling_error": "檢查驗證狀態失敗:",
"kimi_oauth_title": "Kimi OAuth",
"kimi_oauth_button": "開始 Kimi 登入",
"kimi_oauth_hint": "透過裝置授權流程登入 Kimi 服務,自動取得並儲存驗證檔案。",
"kimi_oauth_button": "使用 Kimi 登入",
"kimi_oauth_hint": "透過裝置授權快速登入 Kimi完成後將自動取得並儲存驗證檔案。",
"kimi_oauth_url_label": "授權連結:",
"kimi_open_link": "開啟連結",
"kimi_copy_link": "複製連結",
+105
View File
@@ -13,6 +13,7 @@
.cardTitleIcon {
width: 24px;
height: 24px;
flex: 0 0 auto;
}
.cardTitleIconFallback {
@@ -37,6 +38,110 @@
gap: $spacing-xl;
}
.providerSection {
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.providerList {
display: flex;
flex-direction: column;
gap: $spacing-xl;
}
.sectionTitle {
margin: 0;
color: var(--text-secondary);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.08em;
}
.featuredCard {
position: relative;
overflow: hidden;
border-color: color-mix(in srgb, #1783ff 42%, var(--border-color));
background:
radial-gradient(circle at 92% -35%, rgba(23, 131, 255, 0.24), transparent 42%),
linear-gradient(
135deg,
color-mix(in srgb, #1783ff 10%, var(--bg-primary)),
var(--bg-primary) 64%
);
box-shadow:
0 14px 32px -26px rgba(23, 131, 255, 0.95),
var(--shadow);
&::before {
position: absolute;
inset: 0 auto 0 0;
width: 4px;
background: linear-gradient(180deg, #51a7ff, #1783ff);
content: '';
}
> * {
position: relative;
z-index: 1;
}
.cardTitle {
gap: $spacing-md;
font-size: 21px;
}
.cardTitleIcon {
width: 38px;
height: 38px;
padding: 7px;
border: 1px solid rgba(23, 131, 255, 0.2);
border-radius: 11px;
background: color-mix(in srgb, #1783ff 12%, var(--bg-primary));
}
:global(.btn-primary) {
border-color: #1783ff;
background: #1783ff;
box-shadow: 0 8px 20px -12px rgba(23, 131, 255, 0.9);
&:hover {
border-color: #0875ed;
background: #0875ed;
}
}
@include mobile {
:global(.card-header > .btn) {
width: 100%;
}
}
}
.recommendedBadge {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 3px 9px;
border: 1px solid rgba(23, 131, 255, 0.34);
border-radius: $radius-full;
background: rgba(23, 131, 255, 0.11);
color: color-mix(in srgb, #1783ff 82%, var(--text-primary));
font-size: 12px;
font-weight: 700;
letter-spacing: 0;
line-height: 1;
white-space: nowrap;
}
.featuredHint {
max-width: 720px;
margin: 0;
color: var(--text-secondary);
font-size: 15px;
line-height: 1.65;
}
.cardContent {
display: flex;
flex-direction: column;
+237 -208
View File
@@ -564,224 +564,253 @@ export function OAuthPage() {
}
};
const renderOAuthProviderCard = (provider: OAuthProviderCard, featured = false) => {
const state = states[provider.id] || {};
const canSubmitCallback =
(provider.kind === 'plugin' || CALLBACK_SUPPORTED.has(provider.id)) && Boolean(state.url);
const loginButtonLabel =
state.status === 'success'
? t('auth_login.login_another_account')
: getProviderText(provider, 'oauth_button');
const statusBadgeClassName = [
'status-badge',
state.status === 'success' ? 'success' : '',
state.status === 'error' ? 'error' : '',
]
.filter(Boolean)
.join(' ');
return (
<Card
key={provider.id}
className={featured ? styles.featuredCard : undefined}
title={
<span className={styles.cardTitle}>
<OAuthProviderIcon provider={provider} theme={resolvedTheme} />
<span>{getProviderTitleText(provider)}</span>
{featured && (
<span className={styles.recommendedBadge}>
{t('auth_login.recommended_provider_badge')}
</span>
)}
</span>
}
extra={
<Button onClick={() => startAuth(provider.id)} loading={state.polling}>
{loginButtonLabel}
</Button>
}
>
<div className={styles.cardContent}>
<div className={featured ? styles.featuredHint : styles.cardHint}>
{getProviderText(provider, 'oauth_hint')}
</div>
{state.url && (
<div className={styles.authUrlBox}>
<div className={styles.authUrlLabel}>
{getProviderText(provider, 'oauth_url_label')}
</div>
<div className={styles.authUrlValue}>{state.url}</div>
<div className={styles.authUrlActions}>
<Button variant="secondary" size="sm" onClick={() => copyLink(state.url!)}>
{getProviderText(provider, 'copy_link')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => window.open(state.url, '_blank', 'noopener,noreferrer')}
>
{getProviderText(provider, 'open_link')}
</Button>
</div>
</div>
)}
{canSubmitCallback && (
<div className={styles.callbackSection}>
<Input
label={t(
provider.id === 'xai'
? 'auth_login.xai_callback_label'
: 'auth_login.oauth_callback_label'
)}
hint={t(
provider.id === 'xai'
? 'auth_login.xai_callback_hint'
: 'auth_login.oauth_callback_hint'
)}
value={state.callbackUrl || ''}
onChange={(e) =>
updateProviderState(provider.id, {
callbackUrl: e.target.value,
callbackStatus: undefined,
callbackError: undefined,
})
}
placeholder={t(
provider.id === 'xai'
? 'auth_login.xai_callback_placeholder'
: 'auth_login.oauth_callback_placeholder'
)}
/>
<div className={styles.callbackActions}>
<Button
variant="secondary"
size="sm"
onClick={() => submitCallback(provider.id)}
loading={state.callbackSubmitting}
>
{t('auth_login.oauth_callback_button')}
</Button>
</div>
{state.callbackStatus === 'success' && state.status === 'waiting' && (
<div className="status-badge success">
{t('auth_login.oauth_callback_status_success')}
</div>
)}
{state.callbackStatus === 'error' && (
<div className="status-badge error">
{t('auth_login.oauth_callback_status_error')} {state.callbackError || ''}
</div>
)}
</div>
)}
{state.status && state.status !== 'idle' && (
<div className={statusBadgeClassName}>
{state.status === 'success'
? getProviderText(provider, 'oauth_status_success')
: state.status === 'error'
? `${getProviderText(provider, 'oauth_status_error')} ${state.error || ''}`
: getProviderText(provider, 'oauth_status_waiting')}
</div>
)}
{state.status === 'success' && (
<div className={styles.successActions}>
<Button variant="secondary" size="sm" onClick={() => navigate('/auth-files')}>
{t('auth_login.view_auth_files')}
</Button>
</div>
)}
</div>
</Card>
);
};
const featuredProvider = providerCards.find((provider) => provider.id === 'kimi');
const otherOAuthProviders = providerCards.filter((provider) => provider.id !== 'kimi');
return (
<div className={styles.container}>
<h1 className={styles.pageTitle}>{t('nav.oauth', { defaultValue: 'OAuth' })}</h1>
<div className={styles.content}>
{providerCards.map((provider) => {
const state = states[provider.id] || {};
const canSubmitCallback =
(provider.kind === 'plugin' || CALLBACK_SUPPORTED.has(provider.id)) &&
Boolean(state.url);
const loginButtonLabel =
state.status === 'success'
? t('auth_login.login_another_account')
: getProviderText(provider, 'oauth_button');
const statusBadgeClassName = [
'status-badge',
state.status === 'success' ? 'success' : '',
state.status === 'error' ? 'error' : '',
]
.filter(Boolean)
.join(' ');
return (
<div key={provider.id}>
<Card
title={
<span className={styles.cardTitle}>
<OAuthProviderIcon provider={provider} theme={resolvedTheme} />
{getProviderTitleText(provider)}
</span>
}
extra={
<Button onClick={() => startAuth(provider.id)} loading={state.polling}>
{loginButtonLabel}
</Button>
}
>
<div className={styles.cardContent}>
<div className={styles.cardHint}>{getProviderText(provider, 'oauth_hint')}</div>
{state.url && (
<div className={styles.authUrlBox}>
<div className={styles.authUrlLabel}>
{getProviderText(provider, 'oauth_url_label')}
</div>
<div className={styles.authUrlValue}>{state.url}</div>
<div className={styles.authUrlActions}>
<Button variant="secondary" size="sm" onClick={() => copyLink(state.url!)}>
{getProviderText(provider, 'copy_link')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => window.open(state.url, '_blank', 'noopener,noreferrer')}
>
{getProviderText(provider, 'open_link')}
</Button>
</div>
</div>
)}
{canSubmitCallback && (
<div className={styles.callbackSection}>
<Input
label={t(
provider.id === 'xai'
? 'auth_login.xai_callback_label'
: 'auth_login.oauth_callback_label'
)}
hint={t(
provider.id === 'xai'
? 'auth_login.xai_callback_hint'
: 'auth_login.oauth_callback_hint'
)}
value={state.callbackUrl || ''}
onChange={(e) =>
updateProviderState(provider.id, {
callbackUrl: e.target.value,
callbackStatus: undefined,
callbackError: undefined,
})
}
placeholder={t(
provider.id === 'xai'
? 'auth_login.xai_callback_placeholder'
: 'auth_login.oauth_callback_placeholder'
)}
/>
<div className={styles.callbackActions}>
<Button
variant="secondary"
size="sm"
onClick={() => submitCallback(provider.id)}
loading={state.callbackSubmitting}
>
{t('auth_login.oauth_callback_button')}
</Button>
</div>
{state.callbackStatus === 'success' && state.status === 'waiting' && (
<div className="status-badge success">
{t('auth_login.oauth_callback_status_success')}
</div>
)}
{state.callbackStatus === 'error' && (
<div className="status-badge error">
{t('auth_login.oauth_callback_status_error')} {state.callbackError || ''}
</div>
)}
</div>
)}
{state.status && state.status !== 'idle' && (
<div className={statusBadgeClassName}>
{state.status === 'success'
? getProviderText(provider, 'oauth_status_success')
: state.status === 'error'
? `${getProviderText(provider, 'oauth_status_error')} ${state.error || ''}`
: getProviderText(provider, 'oauth_status_waiting')}
</div>
)}
{state.status === 'success' && (
<div className={styles.successActions}>
<Button variant="secondary" size="sm" onClick={() => navigate('/auth-files')}>
{t('auth_login.view_auth_files')}
</Button>
</div>
)}
</div>
</Card>
</div>
);
})}
{featuredProvider && (
<section className={styles.providerSection}>
<h2 className={styles.sectionTitle}>{t('auth_login.recommended_provider_section')}</h2>
{renderOAuthProviderCard(featuredProvider, true)}
</section>
)}
<section className={styles.providerSection}>
<h2 className={styles.sectionTitle}>{t('auth_login.other_oauth_providers')}</h2>
<div className={styles.providerList}>
{otherOAuthProviders.map((provider) => renderOAuthProviderCard(provider))}
</div>
</section>
{/* Vertex JSON 登录 */}
<Card
title={
<span className={styles.cardTitle}>
<img src={iconVertex} alt="" className={styles.cardTitleIcon} />
{t('vertex_import.title')}
</span>
}
extra={
<Button onClick={handleVertexImport} loading={vertexState.loading}>
{t('vertex_import.import_button')}
</Button>
}
>
<div className={styles.cardContent}>
<div className={styles.cardHint}>{t('vertex_import.description')}</div>
<Input
label={t('vertex_import.location_label')}
hint={t('vertex_import.location_hint')}
value={vertexState.location}
onChange={(e) =>
setVertexState((prev) => ({
...prev,
location: e.target.value,
}))
}
placeholder={t('vertex_import.location_placeholder')}
/>
<div className={styles.formItem}>
<label className={styles.formItemLabel}>{t('vertex_import.file_label')}</label>
<div className={styles.filePicker}>
<Button variant="secondary" size="sm" onClick={handleVertexFilePick}>
{t('vertex_import.choose_file')}
</Button>
<div
className={`${styles.fileName} ${
vertexState.fileName ? '' : styles.fileNamePlaceholder
}`.trim()}
>
{vertexState.fileName || t('vertex_import.file_placeholder')}
</div>
</div>
<div className={styles.cardHintSecondary}>{t('vertex_import.file_hint')}</div>
<input
ref={vertexFileInputRef}
type="file"
accept=".json,application/json"
style={{ display: 'none' }}
onChange={handleVertexFileChange}
<section className={styles.providerSection}>
<h2 className={styles.sectionTitle}>{t('auth_login.other_login_methods')}</h2>
<Card
title={
<span className={styles.cardTitle}>
<img src={iconVertex} alt="" className={styles.cardTitleIcon} />
{t('vertex_import.title')}
</span>
}
extra={
<Button onClick={handleVertexImport} loading={vertexState.loading}>
{t('vertex_import.import_button')}
</Button>
}
>
<div className={styles.cardContent}>
<div className={styles.cardHint}>{t('vertex_import.description')}</div>
<Input
label={t('vertex_import.location_label')}
hint={t('vertex_import.location_hint')}
value={vertexState.location}
onChange={(e) =>
setVertexState((prev) => ({
...prev,
location: e.target.value,
}))
}
placeholder={t('vertex_import.location_placeholder')}
/>
</div>
{vertexState.error && <div className="status-badge error">{vertexState.error}</div>}
{vertexState.result && (
<div className={styles.connectionBox}>
<div className={styles.connectionLabel}>{t('vertex_import.result_title')}</div>
<div className={styles.keyValueList}>
{vertexState.result.projectId && (
<div className={styles.keyValueItem}>
<span className={styles.keyValueKey}>
{t('vertex_import.result_project')}
</span>
<span className={styles.keyValueValue}>{vertexState.result.projectId}</span>
</div>
)}
{vertexState.result.email && (
<div className={styles.keyValueItem}>
<span className={styles.keyValueKey}>{t('vertex_import.result_email')}</span>
<span className={styles.keyValueValue}>{vertexState.result.email}</span>
</div>
)}
{vertexState.result.location && (
<div className={styles.keyValueItem}>
<span className={styles.keyValueKey}>
{t('vertex_import.result_location')}
</span>
<span className={styles.keyValueValue}>{vertexState.result.location}</span>
</div>
)}
{vertexState.result.authFile && (
<div className={styles.keyValueItem}>
<span className={styles.keyValueKey}>{t('vertex_import.result_file')}</span>
<span className={styles.keyValueValue}>{vertexState.result.authFile}</span>
</div>
)}
<div className={styles.formItem}>
<label className={styles.formItemLabel}>{t('vertex_import.file_label')}</label>
<div className={styles.filePicker}>
<Button variant="secondary" size="sm" onClick={handleVertexFilePick}>
{t('vertex_import.choose_file')}
</Button>
<div
className={`${styles.fileName} ${
vertexState.fileName ? '' : styles.fileNamePlaceholder
}`.trim()}
>
{vertexState.fileName || t('vertex_import.file_placeholder')}
</div>
</div>
<div className={styles.cardHintSecondary}>{t('vertex_import.file_hint')}</div>
<input
ref={vertexFileInputRef}
type="file"
accept=".json,application/json"
style={{ display: 'none' }}
onChange={handleVertexFileChange}
/>
</div>
)}
</div>
</Card>
{vertexState.error && <div className="status-badge error">{vertexState.error}</div>}
{vertexState.result && (
<div className={styles.connectionBox}>
<div className={styles.connectionLabel}>{t('vertex_import.result_title')}</div>
<div className={styles.keyValueList}>
{vertexState.result.projectId && (
<div className={styles.keyValueItem}>
<span className={styles.keyValueKey}>
{t('vertex_import.result_project')}
</span>
<span className={styles.keyValueValue}>{vertexState.result.projectId}</span>
</div>
)}
{vertexState.result.email && (
<div className={styles.keyValueItem}>
<span className={styles.keyValueKey}>
{t('vertex_import.result_email')}
</span>
<span className={styles.keyValueValue}>{vertexState.result.email}</span>
</div>
)}
{vertexState.result.location && (
<div className={styles.keyValueItem}>
<span className={styles.keyValueKey}>
{t('vertex_import.result_location')}
</span>
<span className={styles.keyValueValue}>{vertexState.result.location}</span>
</div>
)}
{vertexState.result.authFile && (
<div className={styles.keyValueItem}>
<span className={styles.keyValueKey}>{t('vertex_import.result_file')}</span>
<span className={styles.keyValueValue}>{vertexState.result.authFile}</span>
</div>
)}
</div>
</div>
)}
</div>
</Card>
</section>
</div>
</div>
);