Compare commits

...

15 Commits

Author SHA1 Message Date
Supra4E8C
be3f58f0a8 fix(auth-files): cache Antigravity quota to avoid auto refresh on reopen 2025-12-29 01:18:18 +08:00
Supra4E8C
c299e403cc feat(auth-files): add Antigravity quota page size 2025-12-29 00:48:31 +08:00
Supra4E8C
769c05e459 fix: defult language 2025-12-29 00:17:44 +08:00
Supra4E8C
5ef3406068 fix(config-page): restore page and editor scrolling with fixed card height 2025-12-28 23:50:08 +08:00
Supra4E8C
95cbfb8c59 feat(auth-files): add antigravity quota cards with grouping, pagination, and i18n 2025-12-28 23:39:26 +08:00
Supra4E8C
c17217875c fix(ai-providers): route openai compat model fetch/test through api-call to avoid CORS 2025-12-28 18:10:21 +08:00
Supra4E8C
981f7ac9b2 refactor(i18n): support per-provider empty state and OAuth messages 2025-12-28 17:41:25 +08:00
Supra4E8C
762db81252 fix: lang fix 2025-12-28 11:53:58 +08:00
Supra4E8C
79f6d87d7b fix(api): improve version header parsing for non-plain headers 2025-12-28 10:55:34 +08:00
Supra4E8C
c5d4356d6c fix 2025-12-28 01:04:22 +08:00
Supra4E8C
c989dbf1b6 feat(auth-files): add oauth excluded provider tag 2025-12-28 00:48:36 +08:00
Supra4E8C
3cffa19319 fix(footer): prevent copying management center version text 2025-12-28 00:03:16 +08:00
Supra4E8C
2367f122a8 fix(logs): remove action hint text 2025-12-27 23:59:57 +08:00
Supra4E8C
69a8e1657e fix(layout): keep header fixed on mobile scroll 2025-12-27 23:53:23 +08:00
Supra4E8C
987ce0ec4b feat(modal): add floating close button and collapse animatio 2025-12-27 23:40:56 +08:00
23 changed files with 2245 additions and 1030 deletions

1
.gitignore vendored
View File

@@ -10,6 +10,7 @@ api.md
usage.json
CLAUDE.md
AGENTS.md
antigravity_usage.json
node_modules
dist

View File

@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3Axlink%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%22%20aria-hidden%3D%22true%22%20role%3D%22img%22%20class%3D%22iconify%20iconify--logos%22%20width%3D%2231.88%22%20height%3D%2232%22%20preserveAspectRatio%3D%22xMidYMid%20meet%22%20viewBox%3D%220%200%20256%20257%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22IconifyId1813088fe1fbc01fb466%22%20x1%3D%22-.828%25%22%20x2%3D%2257.636%25%22%20y1%3D%227.652%25%22%20y2%3D%2278.411%25%22%3E%3Cstop%20offset%3D%220%25%22%20stop-color%3D%22%2341D1FF%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%22100%25%22%20stop-color%3D%22%23BD34FE%22%3E%3C%2Fstop%3E%3C%2FlinearGradient%3E%3ClinearGradient%20id%3D%22IconifyId1813088fe1fbc01fb467%22%20x1%3D%2243.376%25%22%20x2%3D%2250.316%25%22%20y1%3D%222.242%25%22%20y2%3D%2289.03%25%22%3E%3Cstop%20offset%3D%220%25%22%20stop-color%3D%22%23FFEA83%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%228.333%25%22%20stop-color%3D%22%23FFDD35%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%22100%25%22%20stop-color%3D%22%23FFA800%22%3E%3C%2Fstop%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Cpath%20fill%3D%22url(%23IconifyId1813088fe1fbc01fb466)%22%20d%3D%22M255.153%2037.938L134.897%20252.976c-2.483%204.44-8.862%204.466-11.382.048L.875%2037.958c-2.746-4.814%201.371-10.646%206.827-9.67l120.385%2021.517a6.537%206.537%200%200%200%202.322-.004l117.867-21.483c5.438-.991%209.574%204.796%206.877%209.62Z%22%3E%3C%2Fpath%3E%3Cpath%20fill%3D%22url(%23IconifyId1813088fe1fbc01fb467)%22%20d%3D%22M185.432.063L96.44%2017.501a3.268%203.268%200%200%200-2.634%203.014l-5.474%2092.456a3.268%203.268%200%200%200%203.997%203.378l24.777-5.718c2.318-.535%204.413%201.507%203.936%203.838l-7.361%2036.047c-.495%202.426%201.782%204.5%204.151%203.78l15.304-4.649c2.372-.72%204.652%201.36%204.15%203.788l-11.698%2056.621c-.732%203.542%203.979%205.473%205.943%202.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505%204.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z%22%3E%3C%2Fpath%3E%3C%2Fsvg%3E" />

View File

@@ -43,6 +43,10 @@ function App() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 仅用于首屏同步 i18n 语言
useEffect(() => {
document.documentElement.lang = language;
}, [language]);
useEffect(() => {
const timer = setTimeout(() => {
setSplashReadyToFade(true);

View File

@@ -514,7 +514,7 @@ export function MainLayout() {
<span>
{t('footer.api_version')}: {serverVersion || t('system_info.version_unknown')}
</span>
<span onClick={handleVersionTap}>
<span className="footer-version" onClick={handleVersionTap}>
{t('footer.version')}: {__APP_VERSION__ || t('system_info.version_unknown')}
</span>
<span>

View File

@@ -1,4 +1,4 @@
import type { PropsWithChildren, ReactNode } from 'react';
import { useState, useEffect, useCallback, useRef, type PropsWithChildren, type ReactNode } from 'react';
import { IconX } from './icons';
interface ModalProps {
@@ -9,23 +9,70 @@ interface ModalProps {
width?: number | string;
}
export function Modal({ open, title, onClose, footer, width = 520, children }: PropsWithChildren<ModalProps>) {
if (!open) return null;
const CLOSE_ANIMATION_DURATION = 350;
const handleMaskClick = (event: React.MouseEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget) {
export function Modal({ open, title, onClose, footer, width = 520, children }: PropsWithChildren<ModalProps>) {
const [isVisible, setIsVisible] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const startClose = useCallback(
(notifyParent: boolean) => {
if (closeTimerRef.current !== null) return;
setIsClosing(true);
closeTimerRef.current = window.setTimeout(() => {
setIsVisible(false);
setIsClosing(false);
closeTimerRef.current = null;
if (notifyParent) {
onClose();
}
}, CLOSE_ANIMATION_DURATION);
},
[onClose]
);
useEffect(() => {
if (open) {
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
setIsVisible(true);
setIsClosing(false);
return;
}
if (isVisible) {
startClose(false);
}
}, [open, isVisible, startClose]);
const handleClose = useCallback(() => {
startClose(true);
}, [startClose]);
useEffect(() => {
return () => {
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current);
}
};
}, []);
if (!open && !isVisible) return null;
const overlayClass = `modal-overlay ${isClosing ? 'modal-overlay-closing' : 'modal-overlay-entering'}`;
const modalClass = `modal ${isClosing ? 'modal-closing' : 'modal-entering'}`;
return (
<div className="modal-overlay" onClick={handleMaskClick}>
<div className="modal" style={{ width }} role="dialog" aria-modal="true">
<div className={overlayClass}>
<div className={modalClass} style={{ width }} role="dialog" aria-modal="true">
<button className="modal-close-floating" onClick={handleClose} aria-label="Close">
<IconX size={20} />
</button>
<div className="modal-header">
<div className="modal-title">{title}</div>
<button className="modal-close" onClick={onClose} aria-label="Close">
<IconX size={18} />
</button>
</div>
<div className="modal-body">{children}</div>
{footer && <div className="modal-footer">{footer}</div>}

View File

@@ -6,14 +6,14 @@ import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import zhCN from './locales/zh-CN.json';
import en from './locales/en.json';
import { STORAGE_KEY_LANGUAGE } from '@/utils/constants';
import { getInitialLanguage } from '@/utils/language';
i18n.use(initReactI18next).init({
resources: {
'zh-CN': { translation: zhCN },
en: { translation: en }
},
lng: localStorage.getItem(STORAGE_KEY_LANGUAGE) || 'zh-CN',
lng: getInitialLanguage(),
fallbackLng: 'zh-CN',
interpolation: {
escapeValue: false // React 已经转义

View File

@@ -357,6 +357,16 @@
"models_excluded_badge": "Excluded",
"models_excluded_hint": "This model is excluded by OAuth"
},
"antigravity_quota": {
"title": "Antigravity Quota",
"empty_title": "No Antigravity Auth Files",
"empty_desc": "Upload an Antigravity credential to view remaining quota.",
"idle": "Not loaded. Click Refresh.",
"loading": "Loading quota...",
"load_failed": "Failed to load quota: {{message}}",
"missing_auth_index": "Auth file missing auth_index",
"empty_models": "No quota data available"
},
"vertex_import": {
"title": "Vertex JSON Login",
"description": "Upload a Google service account JSON to store it as auth-dir/vertex-<project>.json using the same rules as the CLI vertex-import helper.",
@@ -604,8 +614,6 @@
"request_log_download_title": "Download Request Log",
"request_log_download_confirm": "Download request log for ID {{id}}?",
"request_log_download_success": "Request log downloaded successfully",
"action_hint": "Double-click a log line to copy the raw text. Long-press a line with a request ID to download the request log.",
"action_hint_disabled": "Double-click a log line to copy the raw text. Enable request logging to long-press a line with a request ID and download the request log.",
"empty_title": "No Logs Available",
"empty_desc": "When \"Enable logging to file\" is enabled, logs will be displayed here",
"log_content": "Log Content",

View File

@@ -357,6 +357,16 @@
"models_excluded_badge": "已排除",
"models_excluded_hint": "此模型已被 OAuth 排除"
},
"antigravity_quota": {
"title": "Antigravity 额度",
"empty_title": "暂无 Antigravity 认证",
"empty_desc": "上传 Antigravity 认证文件后即可查看额度。",
"idle": "尚未加载额度,请点击刷新。",
"loading": "正在加载额度...",
"load_failed": "额度获取失败:{{message}}",
"missing_auth_index": "认证文件缺少 auth_index",
"empty_models": "暂无额度数据"
},
"vertex_import": {
"title": "Vertex JSON 登录",
"description": "上传 Google 服务账号 JSON使用 CLI vertex-import 同步规则写入 auth-dir/vertex-<project>.json。",
@@ -604,8 +614,6 @@
"request_log_download_title": "下载报文",
"request_log_download_confirm": "是否要下载id为{{id}}的报文?",
"request_log_download_success": "报文下载成功",
"action_hint": "双击日志行可复制原文,长按带有请求 ID 的日志可下载报文。",
"action_hint_disabled": "双击日志行可复制原文,启用请求日志后可长按带请求 ID 的日志下载报文。",
"empty_title": "暂无日志记录",
"empty_desc": "当启用\"日志记录到文件\"功能后,日志将显示在这里",
"log_content": "日志内容",

View File

@@ -11,7 +11,14 @@ import { ModelInputList, modelsToEntries, entriesToModels } from '@/components/u
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { IconCheck, IconX } from '@/components/ui/icons';
import { useAuthStore, useConfigStore, useNotificationStore, useThemeStore } from '@/stores';
import { ampcodeApi, modelsApi, providersApi, usageApi } from '@/services/api';
import {
ampcodeApi,
apiCallApi,
getApiCallErrorMessage,
modelsApi,
providersApi,
usageApi
} from '@/services/api';
import iconGemini from '@/assets/icons/gemini.svg';
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
@@ -91,18 +98,25 @@ const parseExcludedModels = (text: string): string[] =>
const excludedModelsToText = (models?: string[]) =>
Array.isArray(models) ? models.join('\n') : '';
const normalizeOpenAIBaseUrl = (baseUrl: string): string => {
let trimmed = String(baseUrl || '').trim();
if (!trimmed) return '';
trimmed = trimmed.replace(/\/?v0\/management\/?$/i, '');
trimmed = trimmed.replace(/\/+$/g, '');
if (!/^https?:\/\//i.test(trimmed)) {
trimmed = `http://${trimmed}`;
}
return trimmed;
};
const buildOpenAIModelsEndpoint = (baseUrl: string): string => {
const trimmed = String(baseUrl || '')
.trim()
.replace(/\/+$/g, '');
const trimmed = normalizeOpenAIBaseUrl(baseUrl);
if (!trimmed) return '';
return trimmed.endsWith('/v1') ? `${trimmed}/models` : `${trimmed}/v1/models`;
};
const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
const trimmed = String(baseUrl || '')
.trim()
.replace(/\/+$/g, '');
const trimmed = normalizeOpenAIBaseUrl(baseUrl);
if (!trimmed) return '';
if (trimmed.endsWith('/chat/completions')) {
return trimmed;
@@ -483,7 +497,7 @@ export function AiProvidersPage() {
.find((entry) => entry.apiKey?.trim())
?.apiKey?.trim();
const hasAuthHeader = Boolean(headers.Authorization || headers['authorization']);
const list = await modelsApi.fetchModels(
const list = await modelsApi.fetchModelsViaApiCall(
baseUrl,
hasAuthHeader ? undefined : firstKey,
headers
@@ -492,7 +506,7 @@ export function AiProvidersPage() {
} catch (err: any) {
if (allowFallback) {
try {
const list = await modelsApi.fetchModels(baseUrl);
const list = await modelsApi.fetchModelsViaApiCall(baseUrl);
setOpenaiDiscoveryModels(list);
return;
} catch (fallbackErr: any) {
@@ -645,48 +659,40 @@ export function AiProvidersPage() {
setOpenaiTestStatus('loading');
setOpenaiTestMessage(t('ai_providers.openai_test_running'));
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), OPENAI_TEST_TIMEOUT_MS);
try {
const response = await fetch(endpoint, {
const result = await apiCallApi.request(
{
method: 'POST',
headers,
signal: controller.signal,
body: JSON.stringify({
url: endpoint,
header: Object.keys(headers).length ? headers : undefined,
data: JSON.stringify({
model: modelName,
messages: [{ role: 'user', content: 'Hi' }],
stream: false,
max_tokens: 5,
}),
});
const rawText = await response.text();
},
{ timeout: OPENAI_TEST_TIMEOUT_MS }
);
if (!response.ok) {
let errorMessage = `${response.status} ${response.statusText}`;
try {
const parsed = rawText ? JSON.parse(rawText) : null;
errorMessage = parsed?.error?.message || parsed?.message || errorMessage;
} catch {
if (rawText) {
errorMessage = rawText;
}
}
throw new Error(errorMessage);
if (result.statusCode < 200 || result.statusCode >= 300) {
throw new Error(getApiCallErrorMessage(result));
}
setOpenaiTestStatus('success');
setOpenaiTestMessage(t('ai_providers.openai_test_success'));
} catch (err: any) {
setOpenaiTestStatus('error');
if (err?.name === 'AbortError') {
const isTimeout =
err?.code === 'ECONNABORTED' ||
String(err?.message || '').toLowerCase().includes('timeout');
if (isTimeout) {
setOpenaiTestMessage(
t('ai_providers.openai_test_timeout', { seconds: OPENAI_TEST_TIMEOUT_MS / 1000 })
);
} else {
setOpenaiTestMessage(`${t('ai_providers.openai_test_failed')}: ${err?.message || ''}`);
}
} finally {
window.clearTimeout(timeoutId);
}
};
@@ -1216,6 +1222,8 @@ export function AiProvidersPage() {
onEdit: (index: number) => void,
onDelete: (item: T) => void,
addLabel: string,
emptyTitle: string,
emptyDescription: string,
deleteLabel?: string,
options?: {
getRowDisabled?: (item: T, index: number) => boolean;
@@ -1229,8 +1237,8 @@ export function AiProvidersPage() {
if (!items.length) {
return (
<EmptyState
title={t('common.info')}
description={t('ai_providers.gemini_empty_desc')}
title={emptyTitle}
description={emptyDescription}
action={
<Button onClick={() => onEdit(-1)} disabled={disableControls}>
{addLabel}
@@ -1381,6 +1389,8 @@ export function AiProvidersPage() {
(index) => openGeminiModal(index),
(item) => deleteGemini(item.apiKey),
t('ai_providers.gemini_add_button'),
t('ai_providers.gemini_empty_title'),
t('ai_providers.gemini_empty_desc'),
undefined,
{
getRowDisabled: (item) => hasDisableAllModelsRule(item.excludedModels),
@@ -1499,6 +1509,8 @@ export function AiProvidersPage() {
(index) => openProviderModal('codex', index),
(item) => deleteProviderEntry('codex', item.apiKey),
t('ai_providers.codex_add_button'),
t('ai_providers.codex_empty_title'),
t('ai_providers.codex_empty_desc'),
undefined,
{
getRowDisabled: (item) => hasDisableAllModelsRule(item.excludedModels),
@@ -1633,6 +1645,8 @@ export function AiProvidersPage() {
(index) => openProviderModal('claude', index),
(item) => deleteProviderEntry('claude', item.apiKey),
t('ai_providers.claude_add_button'),
t('ai_providers.claude_empty_title'),
t('ai_providers.claude_empty_desc'),
undefined,
{
getRowDisabled: (item) => hasDisableAllModelsRule(item.excludedModels),
@@ -1853,7 +1867,9 @@ export function AiProvidersPage() {
},
(index) => openOpenaiModal(index),
(item) => deleteOpenai(item.name),
t('ai_providers.openai_add_button')
t('ai_providers.openai_add_button'),
t('ai_providers.openai_empty_title'),
t('ai_providers.openai_empty_desc')
)}
</Card>

View File

@@ -162,6 +162,155 @@
}
}
.antigravityGrid {
display: grid;
gap: $spacing-md;
grid-template-columns: repeat(3, minmax(0, 1fr));
@include tablet {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@include mobile {
grid-template-columns: 1fr;
}
}
.antigravityControls {
display: flex;
gap: $spacing-md;
flex-wrap: wrap;
align-items: flex-end;
margin-bottom: $spacing-md;
}
.antigravityControl {
display: flex;
flex-direction: column;
gap: 4px;
label {
font-size: 12px;
color: var(--text-secondary);
font-weight: 500;
white-space: nowrap;
}
}
.antigravityCard {
background-image: linear-gradient(
180deg,
rgba(224, 247, 250, 0.12),
rgba(224, 247, 250, 0)
);
}
.quotaSection {
display: flex;
flex-direction: column;
gap: $spacing-sm;
padding-top: $spacing-sm;
margin-top: $spacing-xs;
border-top: 1px dashed var(--border-color);
}
.quotaRow {
display: flex;
flex-direction: column;
gap: $spacing-xs;
}
.quotaRowHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-sm;
min-width: 0;
@include mobile {
flex-direction: column;
align-items: flex-start;
}
}
.quotaModel {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
@include mobile {
white-space: normal;
}
}
.quotaBar {
height: 8px;
background-color: var(--bg-tertiary);
border-radius: 999px;
overflow: hidden;
}
.quotaBarFill {
height: 100%;
background-color: var(--success-color, #22c55e);
transition: width 0.2s ease;
}
.quotaBarFillHigh {
background-color: var(--success-color, #22c55e);
}
.quotaBarFillMedium {
background-color: var(--warning-color, #f59e0b);
}
.quotaBarFillLow {
background-color: var(--danger-color, #ef4444);
}
.quotaMeta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
@include mobile {
justify-content: flex-start;
}
}
.quotaPercent {
font-weight: 600;
color: var(--text-primary);
}
.quotaReset {
color: var(--text-tertiary);
}
.quotaMessage {
font-size: 12px;
color: var(--text-tertiary);
text-align: center;
padding: $spacing-sm 0;
}
.quotaError {
font-size: 12px;
color: var(--danger-color);
background-color: rgba(239, 68, 68, 0.08);
border: 1px solid var(--danger-color);
border-radius: $radius-sm;
padding: $spacing-xs $spacing-sm;
}
// 单个认证文件卡片
.fileCard {
background-color: var(--bg-primary);
@@ -422,6 +571,60 @@
flex-shrink: 0;
}
// OAuth 排除列表表单:提供商快捷标签
.providerField {
display: flex;
flex-direction: column;
gap: $spacing-xs;
:global(.form-group) {
margin-bottom: 0;
}
}
.providerTagList {
display: flex;
flex-wrap: wrap;
gap: $spacing-xs;
}
.providerTag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: $radius-full;
border: 1px solid var(--border-color);
background-color: var(--bg-secondary);
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: all $transition-fast;
&:hover {
border-color: var(--primary-color);
color: var(--text-primary);
background-color: var(--bg-hover);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.providerTagActive {
background-color: var(--primary-color);
border-color: var(--primary-color);
color: #fff;
&:hover {
background-color: var(--primary-color);
border-color: var(--primary-color);
color: #fff;
}
}
// 详情弹窗
.detailContent {
max-height: 400px;

View File

@@ -9,7 +9,7 @@ import { Modal } from '@/components/ui/Modal';
import { EmptyState } from '@/components/ui/EmptyState';
import { IconBot, IconDownload, IconInfo, IconTrash2 } from '@/components/ui/icons';
import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
import { authFilesApi, usageApi } from '@/services/api';
import { apiCallApi, authFilesApi, getApiCallErrorMessage, usageApi } from '@/services/api';
import { apiClient } from '@/services/api/client';
import type { AuthFileItem } from '@/types';
import type { KeyStats, KeyStatBucket, UsageDetail } from '@/utils/usage';
@@ -65,11 +65,116 @@ const TYPE_COLORS: Record<string, TypeColorSet> = {
}
};
const OAUTH_PROVIDER_PRESETS = [
'gemini',
'gemini-cli',
'vertex',
'aistudio',
'antigravity',
'claude',
'codex',
'qwen',
'iflow'
];
const OAUTH_PROVIDER_EXCLUDES = new Set(['all', 'unknown', 'empty']);
interface ExcludedFormState {
provider: string;
modelsText: string;
}
interface AntigravityQuotaGroup {
id: string;
label: string;
models: string[];
remainingFraction: number;
resetTime?: string;
}
interface AntigravityQuotaState {
status: 'idle' | 'loading' | 'success' | 'error';
groups: AntigravityQuotaGroup[];
error?: string;
}
interface AntigravityQuotaInfo {
displayName?: string;
quotaInfo?: {
remainingFraction?: number | string;
remaining_fraction?: number | string;
remaining?: number | string;
resetTime?: string;
reset_time?: string;
};
quota_info?: {
remainingFraction?: number | string;
remaining_fraction?: number | string;
remaining?: number | string;
resetTime?: string;
reset_time?: string;
};
}
type AntigravityModelsPayload = Record<string, AntigravityQuotaInfo>;
interface AntigravityQuotaGroupDefinition {
id: string;
label: string;
identifiers: string[];
labelFromModel?: boolean;
}
const ANTIGRAVITY_QUOTA_URLS = [
'https://cloudcode-pa-pa.sandbox.googleapis.com/v1internal:fetchAvailableModels',
'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:fetchAvailableModels',
'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels'
];
const ANTIGRAVITY_REQUEST_HEADERS = {
Authorization: 'Bearer $TOKEN$',
'Content-Type': 'application/json',
'User-Agent': 'antigravity/1.11.5 windows/amd64'
};
const ANTIGRAVITY_QUOTA_GROUPS: AntigravityQuotaGroupDefinition[] = [
{
id: 'claude-gpt',
label: 'Claude/GPT',
identifiers: [
'claude-sonnet-4-5-thinking',
'claude-opus-4-5-thinking',
'claude-sonnet-4-5',
'gpt-oss-120b-medium'
]
},
{
id: 'gemini',
label: 'Gemini',
identifiers: [
'gemini-3-pro-high',
'gemini-3-pro-low',
'gemini-2.5-flash',
'gemini-2.5-flash-lite',
'rev19-uic3-1p'
]
},
{
id: 'gemini-3-flash',
label: 'Gemini 3 Flash',
identifiers: ['gemini-3-flash']
},
{
id: 'gemini-image',
label: 'gemini-3-pro-image',
identifiers: ['gemini-3-pro-image'],
labelFromModel: true
}
];
let antigravityQuotaCache: Record<string, AntigravityQuotaState> = {};
let antigravityQuotaCacheLoaded = false;
// 标准化 auth_index 值(与 usage.ts 中的 normalizeAuthIndex 保持一致)
function normalizeAuthIndexValue(value: unknown): string | null {
if (typeof value === 'number' && Number.isFinite(value)) {
@@ -82,6 +187,155 @@ function normalizeAuthIndexValue(value: unknown): string | null {
return null;
}
function parseAntigravityPayload(payload: unknown): Record<string, unknown> | null {
if (payload === undefined || payload === null) return null;
if (typeof payload === 'string') {
const trimmed = payload.trim();
if (!trimmed) return null;
try {
return JSON.parse(trimmed) as Record<string, unknown>;
} catch {
return null;
}
}
if (typeof payload === 'object') {
return payload as Record<string, unknown>;
}
return null;
}
function getAntigravityQuotaInfo(entry?: AntigravityQuotaInfo): {
remainingFraction: number | null;
resetTime?: string;
displayName?: string;
} {
if (!entry) {
return { remainingFraction: null };
}
const quotaInfo = entry.quotaInfo ?? entry.quota_info ?? {};
const remainingValue =
quotaInfo.remainingFraction ?? quotaInfo.remaining_fraction ?? quotaInfo.remaining;
const remainingFraction = Number(remainingValue);
const resetValue = quotaInfo.resetTime ?? quotaInfo.reset_time;
const resetTime = typeof resetValue === 'string' ? resetValue : undefined;
const displayName = typeof entry.displayName === 'string' ? entry.displayName : undefined;
return {
remainingFraction: Number.isFinite(remainingFraction) ? remainingFraction : null,
resetTime,
displayName
};
}
function findAntigravityModel(
models: AntigravityModelsPayload,
identifier: string
): { id: string; entry: AntigravityQuotaInfo } | null {
const direct = models[identifier];
if (direct) {
return { id: identifier, entry: direct };
}
const match = Object.entries(models).find(([, entry]) => {
const name = typeof entry?.displayName === 'string' ? entry.displayName : '';
return name.toLowerCase() === identifier.toLowerCase();
});
if (match) {
return { id: match[0], entry: match[1] };
}
return null;
}
function buildAntigravityQuotaGroups(models: AntigravityModelsPayload): AntigravityQuotaGroup[] {
const groups: AntigravityQuotaGroup[] = [];
let geminiResetTime: string | undefined;
const [claudeDef, geminiDef, flashDef, imageDef] = ANTIGRAVITY_QUOTA_GROUPS;
const buildGroup = (
def: AntigravityQuotaGroupDefinition,
overrideResetTime?: string
): AntigravityQuotaGroup | null => {
const matches = def.identifiers
.map((identifier) => findAntigravityModel(models, identifier))
.filter((entry): entry is { id: string; entry: AntigravityQuotaInfo } => Boolean(entry));
const quotaEntries = matches
.map(({ id, entry }) => {
const info = getAntigravityQuotaInfo(entry);
if (info.remainingFraction === null) return null;
return {
id,
remainingFraction: info.remainingFraction,
resetTime: info.resetTime,
displayName: info.displayName
};
})
.filter((entry): entry is NonNullable<typeof entry> => entry !== null);
if (quotaEntries.length === 0) return null;
const remainingFraction = Math.min(...quotaEntries.map((entry) => entry.remainingFraction));
const resetTime =
overrideResetTime ?? quotaEntries.map((entry) => entry.resetTime).find(Boolean);
const displayName = quotaEntries.map((entry) => entry.displayName).find(Boolean);
const label = def.labelFromModel && displayName ? displayName : def.label;
return {
id: def.id,
label,
models: quotaEntries.map((entry) => entry.id),
remainingFraction,
resetTime
};
};
const claudeGroup = buildGroup(claudeDef);
if (claudeGroup) {
groups.push(claudeGroup);
}
const geminiGroup = buildGroup(geminiDef);
if (geminiGroup) {
geminiResetTime = geminiGroup.resetTime;
groups.push(geminiGroup);
}
const flashGroup = buildGroup(flashDef);
if (flashGroup) {
groups.push(flashGroup);
}
const imageGroup = buildGroup(imageDef, geminiResetTime);
if (imageGroup) {
groups.push(imageGroup);
}
return groups;
}
function formatQuotaResetTime(value?: string): string {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return date.toLocaleString(undefined, {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
}
function resolveAuthProvider(file: AuthFileItem): string {
const raw = file.provider ?? file.type ?? '';
return String(raw).trim().toLowerCase();
}
function isAntigravityFile(file: AuthFileItem): boolean {
return resolveAuthProvider(file) === 'antigravity';
}
function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean {
const raw = file['runtime_only'] ?? file.runtimeOnly;
if (typeof raw === 'boolean') return raw;
@@ -141,11 +395,17 @@ export function AuthFilesPage() {
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(9);
const [antigravityPage, setAntigravityPage] = useState(1);
const [antigravityPageSize, setAntigravityPageSize] = useState(6);
const [uploading, setUploading] = useState(false);
const [deleting, setDeleting] = useState<string | null>(null);
const [deletingAll, setDeletingAll] = useState(false);
const [keyStats, setKeyStats] = useState<KeyStats>({ bySource: {}, byAuthIndex: {} });
const [usageDetails, setUsageDetails] = useState<UsageDetail[]>([]);
const [antigravityQuota, setAntigravityQuota] = useState<Record<string, AntigravityQuotaState>>(
{}
);
const [antigravityLoading, setAntigravityLoading] = useState(false);
// 详情弹窗相关
const [detailModalOpen, setDetailModalOpen] = useState(false);
@@ -168,6 +428,8 @@ export function AuthFilesPage() {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const loadingKeyStatsRef = useRef(false);
const antigravityLoadingRef = useRef(false);
const antigravityRequestIdRef = useRef(0);
const excludedUnsupportedRef = useRef(false);
const disableControls = connectionStatus !== 'connected';
@@ -245,12 +507,162 @@ export function AuthFilesPage() {
}
}, [showNotification, t]);
const antigravityFiles = useMemo(
() => files.filter((file) => isAntigravityFile(file)),
[files]
);
const antigravityTotalPages = Math.max(
1,
Math.ceil(antigravityFiles.length / antigravityPageSize)
);
const antigravityCurrentPage = Math.min(antigravityPage, antigravityTotalPages);
const antigravityStart = (antigravityCurrentPage - 1) * antigravityPageSize;
const antigravityPageItems = antigravityFiles.slice(
antigravityStart,
antigravityStart + antigravityPageSize
);
const fetchAntigravityQuota = useCallback(
async (authIndex: string): Promise<AntigravityQuotaGroup[]> => {
let lastError = '';
let hadSuccess = false;
for (const url of ANTIGRAVITY_QUOTA_URLS) {
try {
const result = await apiCallApi.request({
authIndex,
method: 'POST',
url,
header: { ...ANTIGRAVITY_REQUEST_HEADERS },
data: '{}'
});
if (result.statusCode < 200 || result.statusCode >= 300) {
lastError = getApiCallErrorMessage(result);
continue;
}
hadSuccess = true;
const payload = parseAntigravityPayload(result.body ?? result.bodyText);
const models = payload?.models;
if (!models || typeof models !== 'object' || Array.isArray(models)) {
lastError = t('antigravity_quota.empty_models');
continue;
}
const groups = buildAntigravityQuotaGroups(models as AntigravityModelsPayload);
if (groups.length === 0) {
lastError = t('antigravity_quota.empty_models');
continue;
}
return groups;
} catch (err: unknown) {
lastError = err instanceof Error ? err.message : t('common.unknown_error');
}
}
if (hadSuccess) {
return [];
}
throw new Error(lastError || t('common.unknown_error'));
},
[t]
);
const loadAntigravityQuota = useCallback(async () => {
if (antigravityLoadingRef.current) return;
antigravityLoadingRef.current = true;
const requestId = ++antigravityRequestIdRef.current;
setAntigravityLoading(true);
try {
if (antigravityFiles.length === 0) {
setAntigravityQuota({});
return;
}
const loadingState: Record<string, AntigravityQuotaState> = {};
antigravityFiles.forEach((file) => {
loadingState[file.name] = { status: 'loading', groups: [] };
});
setAntigravityQuota(loadingState);
const results = await Promise.all(
antigravityFiles.map(async (file) => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
if (!authIndex) {
return {
name: file.name,
status: 'error' as const,
error: t('antigravity_quota.missing_auth_index')
};
}
try {
const groups = await fetchAntigravityQuota(authIndex);
return { name: file.name, status: 'success' as const, groups };
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('common.unknown_error');
return { name: file.name, status: 'error' as const, error: message };
}
})
);
if (requestId !== antigravityRequestIdRef.current) return;
const nextState: Record<string, AntigravityQuotaState> = {};
results.forEach((result) => {
if (result.status === 'success') {
nextState[result.name] = {
status: 'success',
groups: result.groups
};
} else {
nextState[result.name] = {
status: 'error',
groups: [],
error: result.error
};
}
});
setAntigravityQuota(nextState);
antigravityQuotaCache = nextState;
antigravityQuotaCacheLoaded = true;
} finally {
if (requestId === antigravityRequestIdRef.current) {
setAntigravityLoading(false);
antigravityLoadingRef.current = false;
}
}
}, [antigravityFiles, fetchAntigravityQuota, t]);
useEffect(() => {
loadFiles();
loadKeyStats();
loadExcluded();
}, [loadFiles, loadKeyStats, loadExcluded]);
useEffect(() => {
if (antigravityFiles.length === 0) {
setAntigravityQuota({});
return;
}
if (antigravityQuotaCacheLoaded) {
setAntigravityQuota(antigravityQuotaCache);
return;
}
loadAntigravityQuota();
}, [
antigravityFiles,
loadAntigravityQuota,
antigravityQuotaCacheLoaded,
antigravityQuotaCache
]);
// 定时刷新状态数据每240秒
useInterval(loadKeyStats, 240_000);
@@ -265,6 +677,45 @@ export function AuthFilesPage() {
return Array.from(types);
}, [files]);
const excludedProviderLookup = useMemo(() => {
const lookup = new Map<string, string>();
Object.keys(excluded).forEach((provider) => {
const key = provider.trim().toLowerCase();
if (key && !lookup.has(key)) {
lookup.set(key, provider);
}
});
return lookup;
}, [excluded]);
const providerOptions = useMemo(() => {
const extraProviders = new Set<string>();
Object.keys(excluded).forEach((provider) => {
extraProviders.add(provider);
});
files.forEach((file) => {
if (typeof file.type === 'string') {
extraProviders.add(file.type);
}
if (typeof file.provider === 'string') {
extraProviders.add(file.provider);
}
});
const normalizedExtras = Array.from(extraProviders)
.map((value) => value.trim())
.filter((value) => value && !OAUTH_PROVIDER_EXCLUDES.has(value.toLowerCase()));
const baseSet = new Set(OAUTH_PROVIDER_PRESETS.map((value) => value.toLowerCase()));
const extraList = normalizedExtras
.filter((value) => !baseSet.has(value.toLowerCase()))
.sort((a, b) => a.localeCompare(b));
return [...OAUTH_PROVIDER_PRESETS, ...extraList];
}, [excluded, files]);
// 过滤和搜索
const filtered = useMemo(() => {
return files.filter((item) => {
@@ -511,9 +962,14 @@ export function AuthFilesPage() {
// OAuth 排除相关方法
const openExcludedModal = (provider?: string) => {
const models = provider ? excluded[provider] : [];
const normalizedProvider = (provider || '').trim();
const fallbackProvider = normalizedProvider || (filter !== 'all' ? String(filter) : '');
const lookupKey = fallbackProvider
? excludedProviderLookup.get(fallbackProvider.toLowerCase())
: undefined;
const models = lookupKey ? excluded[lookupKey] : [];
setExcludedForm({
provider: provider || '',
provider: lookupKey || fallbackProvider,
modelsText: Array.isArray(models) ? models.join('\n') : ''
});
setExcludedModalOpen(true);
@@ -741,6 +1197,81 @@ export function AuthFilesPage() {
);
};
const renderAntigravityCard = (item: AuthFileItem) => {
const displayType = item.type || item.provider || 'antigravity';
const typeColor = getTypeColor(displayType);
const quotaState = antigravityQuota[item.name];
const quotaStatus =
quotaState?.status ??
(antigravityLoading || !antigravityQuotaCacheLoaded ? 'loading' : 'idle');
const quotaGroups = quotaState?.groups ?? [];
return (
<div key={item.name} className={`${styles.fileCard} ${styles.antigravityCard}`}>
<div className={styles.cardHeader}>
<span
className={styles.typeBadge}
style={{
backgroundColor: typeColor.bg,
color: typeColor.text,
...(typeColor.border ? { border: typeColor.border } : {})
}}
>
{getTypeLabel(displayType)}
</span>
<span className={styles.fileName}>{item.name}</span>
</div>
<div className={styles.quotaSection}>
{quotaStatus === 'loading' ? (
<div className={styles.quotaMessage}>{t('antigravity_quota.loading')}</div>
) : quotaStatus === 'idle' ? (
<div className={styles.quotaMessage}>{t('antigravity_quota.idle')}</div>
) : quotaStatus === 'error' ? (
<div className={styles.quotaError}>
{t('antigravity_quota.load_failed', {
message: quotaState?.error || t('common.unknown_error')
})}
</div>
) : quotaGroups.length === 0 ? (
<div className={styles.quotaMessage}>{t('antigravity_quota.empty_models')}</div>
) : (
quotaGroups.map((group) => {
const clamped = Math.max(0, Math.min(1, group.remainingFraction));
const percent = Math.round(clamped * 100);
const resetLabel = formatQuotaResetTime(group.resetTime);
const quotaBarClass =
percent >= 60
? styles.quotaBarFillHigh
: percent >= 20
? styles.quotaBarFillMedium
: styles.quotaBarFillLow;
return (
<div key={group.id} className={styles.quotaRow}>
<div className={styles.quotaRowHeader}>
<span className={styles.quotaModel} title={group.models.join(', ')}>
{group.label}
</span>
<div className={styles.quotaMeta}>
<span className={styles.quotaPercent}>{percent}%</span>
<span className={styles.quotaReset}>{resetLabel}</span>
</div>
</div>
<div className={styles.quotaBar}>
<div
className={`${styles.quotaBarFill} ${quotaBarClass}`}
style={{ width: `${percent}%` }}
/>
</div>
</div>
);
})
)}
</div>
</div>
);
};
return (
<div className={styles.container}>
<div className={styles.pageHeader}>
@@ -863,6 +1394,88 @@ export function AuthFilesPage() {
)}
</Card>
<Card
title={t('antigravity_quota.title')}
extra={
<Button
variant="secondary"
size="sm"
onClick={loadAntigravityQuota}
disabled={disableControls || antigravityLoading || antigravityFiles.length === 0}
loading={antigravityLoading}
>
{t('common.refresh')}
</Button>
}
>
{antigravityFiles.length === 0 ? (
<EmptyState
title={t('antigravity_quota.empty_title')}
description={t('antigravity_quota.empty_desc')}
/>
) : (
<>
<div className={styles.antigravityControls}>
<div className={styles.antigravityControl}>
<label>{t('auth_files.page_size_label')}</label>
<select
className={styles.pageSizeSelect}
value={antigravityPageSize}
onChange={(e) => {
setAntigravityPageSize(Number(e.target.value) || 6);
setAntigravityPage(1);
}}
>
<option value={6}>6</option>
<option value={9}>9</option>
<option value={12}>12</option>
<option value={18}>18</option>
<option value={24}>24</option>
</select>
</div>
<div className={styles.antigravityControl}>
<label>{t('common.info')}</label>
<div className={styles.statsInfo}>
{antigravityFiles.length} {t('auth_files.files_count')}
</div>
</div>
</div>
<div className={styles.antigravityGrid}>
{antigravityPageItems.map(renderAntigravityCard)}
</div>
{antigravityFiles.length > antigravityPageSize && (
<div className={styles.pagination}>
<Button
variant="secondary"
size="sm"
onClick={() => setAntigravityPage(Math.max(1, antigravityCurrentPage - 1))}
disabled={antigravityCurrentPage <= 1}
>
{t('auth_files.pagination_prev')}
</Button>
<div className={styles.pageInfo}>
{t('auth_files.pagination_info', {
current: antigravityCurrentPage,
total: antigravityTotalPages,
count: antigravityFiles.length
})}
</div>
<Button
variant="secondary"
size="sm"
onClick={() =>
setAntigravityPage(Math.min(antigravityTotalPages, antigravityCurrentPage + 1))
}
disabled={antigravityCurrentPage >= antigravityTotalPages}
>
{t('auth_files.pagination_next')}
</Button>
</div>
)}
</>
)}
</Card>
{/* OAuth 排除列表卡片 */}
<Card
title={t('oauth_excluded.title')}
@@ -1011,12 +1624,41 @@ export function AuthFilesPage() {
</>
}
>
<div className={styles.providerField}>
<Input
id="oauth-excluded-provider"
list="oauth-excluded-provider-options"
label={t('oauth_excluded.provider_label')}
hint={t('oauth_excluded.provider_hint')}
placeholder={t('oauth_excluded.provider_placeholder')}
value={excludedForm.provider}
onChange={(e) => setExcludedForm((prev) => ({ ...prev, provider: e.target.value }))}
/>
<datalist id="oauth-excluded-provider-options">
{providerOptions.map((provider) => (
<option key={provider} value={provider} />
))}
</datalist>
{providerOptions.length > 0 && (
<div className={styles.providerTagList}>
{providerOptions.map((provider) => {
const isActive =
excludedForm.provider.trim().toLowerCase() === provider.toLowerCase();
return (
<button
key={provider}
type="button"
className={`${styles.providerTag} ${isActive ? styles.providerTagActive : ''}`}
onClick={() => setExcludedForm((prev) => ({ ...prev, provider }))}
disabled={savingExcluded}
>
{getTypeLabel(provider)}
</button>
);
})}
</div>
)}
</div>
<div className={styles.formGroup}>
<label>{t('oauth_excluded.models_label')}</label>
<textarea

View File

@@ -2,11 +2,10 @@
.container {
width: 100%;
height: 100%;
flex: 1;
min-height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
overflow-y: auto;
}
.pageTitle {
@@ -134,8 +133,8 @@
.editorWrapper {
width: 100%;
flex: 0 0 auto;
height: 480px;
flex: 1;
min-height: 400px;
border: 1px solid var(--border-color);
border-radius: $radius-lg;
overflow: hidden;
@@ -220,9 +219,9 @@
.configCard {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
height: 560px;
flex-shrink: 0;
overflow: visible;
}
.actions {
@@ -254,10 +253,11 @@
}
.configCard {
height: 440px;
padding: $spacing-md;
}
.editorWrapper {
height: 360px;
min-height: 300px;
}
}

View File

@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { IconEye, IconEyeOff } from '@/components/ui/icons';
import { useAuthStore, useNotificationStore } from '@/stores';
import { useAuthStore, useLanguageStore, useNotificationStore } from '@/stores';
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
export function LoginPage() {
@@ -12,6 +12,8 @@ export function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const { showNotification } = useNotificationStore();
const language = useLanguageStore((state) => state.language);
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const login = useAuthStore((state) => state.login);
const restoreSession = useAuthStore((state) => state.restoreSession);
@@ -27,6 +29,7 @@ export function LoginPage() {
const [error, setError] = useState('');
const detectedBase = useMemo(() => detectApiBaseFromLocation(), []);
const nextLanguageLabel = language === 'zh-CN' ? t('language.english') : t('language.chinese');
useEffect(() => {
const init = async () => {
@@ -49,10 +52,6 @@ export function LoginPage() {
return <Navigate to={redirect} replace />;
}
const handleUseCurrent = () => {
setApiBase(detectedBase);
};
const handleSubmit = async () => {
if (!managementKey.trim()) {
setError(t('login.error_required'));
@@ -79,7 +78,20 @@ export function LoginPage() {
<div className="login-page">
<div className="login-card">
<div className="login-header">
<div className="login-title-row">
<div className="title">{t('title.login')}</div>
<Button
type="button"
variant="ghost"
size="sm"
className="login-language-btn"
onClick={toggleLanguage}
title={t('language.switch')}
aria-label={t('language.switch')}
>
{nextLanguageLabel}
</Button>
</div>
<div className="subtitle">{t('login.subtitle')}</div>
</div>
@@ -136,14 +148,9 @@ export function LoginPage() {
}
/>
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<Button variant="secondary" onClick={handleUseCurrent}>
{t('login.use_current_address')}
</Button>
<Button fullWidth onClick={handleSubmit} loading={loading}>
{loading ? t('login.submitting') : t('login.submit_button')}
</Button>
</div>
{error && <div className="error-box">{error}</div>}

View File

@@ -851,10 +851,6 @@ export function LogsPage() {
</div>
</div>
<div className="hint">
{requestLogEnabled ? t('logs.action_hint') : t('logs.action_hint_disabled')}
</div>
{loading ? (
<div className="hint">{t('logs.loading')}</div>
) : logState.buffer.length > 0 && parsedVisibleLines.length > 0 ? (

View File

@@ -64,6 +64,9 @@ const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabe
];
const CALLBACK_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli', 'iflow'];
const getProviderI18nPrefix = (provider: OAuthProvider) => provider.replace('-', '_');
const getAuthKey = (provider: OAuthProvider, suffix: string) =>
`auth_login.${getProviderI18nPrefix(provider)}_${suffix}`;
const getIcon = (icon: string | { light: string; dark: string }, theme: 'light' | 'dark') => {
return typeof icon === 'string' ? icon : icon[theme];
@@ -105,12 +108,15 @@ export function OAuthPage() {
const res = await oauthApi.getAuthStatus(state);
if (res.status === 'ok') {
updateProviderState(provider, { status: 'success', polling: false });
showNotification(t('auth_login.codex_oauth_status_success'), 'success');
showNotification(t(getAuthKey(provider, 'oauth_status_success')), 'success');
window.clearInterval(timer);
delete timers.current[provider];
} else if (res.status === 'error') {
updateProviderState(provider, { status: 'error', error: res.error, polling: false });
showNotification(`${t('auth_login.codex_oauth_status_error')} ${res.error || ''}`, 'error');
showNotification(
`${t(getAuthKey(provider, 'oauth_status_error'))} ${res.error || ''}`,
'error'
);
window.clearInterval(timer);
delete timers.current[provider];
}
@@ -153,7 +159,7 @@ export function OAuthPage() {
}
} catch (err: any) {
updateProviderState(provider, { status: 'error', error: err?.message, polling: false });
showNotification(`${t('auth_login.codex_oauth_start_error')} ${err?.message || ''}`, 'error');
showNotification(`${t(getAuthKey(provider, 'oauth_start_error'))} ${err?.message || ''}`, 'error');
}
};
@@ -347,14 +353,14 @@ export function OAuthPage() {
<div className={styles.authUrlValue}>{state.url}</div>
<div className={styles.authUrlActions}>
<Button variant="secondary" size="sm" onClick={() => copyLink(state.url!)}>
{t('auth_login.codex_copy_link')}
{t(getAuthKey(provider.id, 'copy_link'))}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => window.open(state.url, '_blank', 'noopener,noreferrer')}
>
{t('auth_login.codex_open_link')}
{t(getAuthKey(provider.id, 'open_link'))}
</Button>
</div>
</div>
@@ -399,10 +405,10 @@ export function OAuthPage() {
{state.status && state.status !== 'idle' && (
<div className="status-badge" style={{ marginTop: 8 }}>
{state.status === 'success'
? t('auth_login.codex_oauth_status_success')
? t(getAuthKey(provider.id, 'oauth_status_success'))
: state.status === 'error'
? `${t('auth_login.codex_oauth_status_error')} ${state.error || ''}`
: t('auth_login.codex_oauth_status_waiting')}
? `${t(getAuthKey(provider.id, 'oauth_status_error'))} ${state.error || ''}`
: t(getAuthKey(provider.id, 'oauth_status_waiting'))}
</div>
)}
</Card>

View File

@@ -0,0 +1,86 @@
/**
* Generic API call helper (proxied via management API).
*/
import type { AxiosRequestConfig } from 'axios';
import { apiClient } from './client';
export interface ApiCallRequest {
authIndex?: string;
method: string;
url: string;
header?: Record<string, string>;
data?: string;
}
export interface ApiCallResult<T = any> {
statusCode: number;
header: Record<string, string[]>;
bodyText: string;
body: T | null;
}
const normalizeBody = (input: unknown): { bodyText: string; body: any | null } => {
if (input === undefined || input === null) {
return { bodyText: '', body: null };
}
if (typeof input === 'string') {
const text = input;
const trimmed = text.trim();
if (!trimmed) {
return { bodyText: text, body: null };
}
try {
return { bodyText: text, body: JSON.parse(trimmed) };
} catch {
return { bodyText: text, body: text };
}
}
try {
return { bodyText: JSON.stringify(input), body: input };
} catch {
return { bodyText: String(input), body: input };
}
};
export const getApiCallErrorMessage = (result: ApiCallResult): string => {
const status = result.statusCode;
const body = result.body;
const bodyText = result.bodyText;
let message = '';
if (body && typeof body === 'object') {
message = body?.error?.message || body?.error || body?.message || '';
} else if (typeof body === 'string') {
message = body;
}
if (!message && bodyText) {
message = bodyText;
}
if (status && message) return `${status} ${message}`.trim();
if (status) return `HTTP ${status}`;
return message || 'Request failed';
};
export const apiCallApi = {
request: async (
payload: ApiCallRequest,
config?: AxiosRequestConfig
): Promise<ApiCallResult> => {
const response = await apiClient.post('/api-call', payload, config);
const statusCode = Number(response?.status_code ?? response?.statusCode ?? 0);
const header = (response?.header ?? response?.headers ?? {}) as Record<string, string[]>;
const { bodyText, body } = normalizeBody(response?.body);
return {
statusCode,
header,
bodyText,
body
};
}
};

View File

@@ -62,12 +62,37 @@ class ApiClient {
return `${normalized}${MANAGEMENT_API_PREFIX}`;
}
private readHeader(headers: Record<string, any>, keys: string[]): string | null {
private readHeader(headers: Record<string, any> | undefined, keys: string[]): string | null {
if (!headers) return null;
const normalizeValue = (value: unknown): string | null => {
if (value === undefined || value === null) return null;
if (Array.isArray(value)) {
const first = value.find((entry) => entry !== undefined && entry !== null && String(entry).trim());
return first !== undefined ? String(first) : null;
}
const text = String(value);
return text ? text : null;
};
const headerGetter = (headers as { get?: (name: string) => any }).get;
if (typeof headerGetter === 'function') {
for (const key of keys) {
const match = normalizeValue(headerGetter.call(headers, key));
if (match) return match;
}
}
const entries =
typeof (headers as { entries?: () => Iterable<[string, any]> }).entries === 'function'
? Array.from((headers as { entries: () => Iterable<[string, any]> }).entries())
: Object.entries(headers);
const normalized = Object.fromEntries(
Object.entries(headers || {}).map(([key, value]) => [key.toLowerCase(), value as string | undefined])
entries.map(([key, value]) => [String(key).toLowerCase(), value])
);
for (const key of keys) {
const match = normalized[key.toLowerCase()];
const match = normalizeValue(normalized[key.toLowerCase()]);
if (match) return match;
}
return null;

View File

@@ -1,4 +1,5 @@
export * from './client';
export * from './apiCall';
export * from './config';
export * from './configFile';
export * from './apiKeys';

View File

@@ -4,6 +4,7 @@
import axios from 'axios';
import { normalizeModelList } from '@/utils/models';
import { apiCallApi, getApiCallErrorMessage } from './apiCall';
const normalizeBaseUrl = (baseUrl: string): string => {
let normalized = String(baseUrl || '').trim();
@@ -39,5 +40,35 @@ export const modelsApi = {
});
const payload = response.data?.data ?? response.data?.models ?? response.data;
return normalizeModelList(payload, { dedupe: true });
},
async fetchModelsViaApiCall(
baseUrl: string,
apiKey?: string,
headers: Record<string, string> = {}
) {
const endpoint = buildModelsEndpoint(baseUrl);
if (!endpoint) {
throw new Error('Invalid base url');
}
const resolvedHeaders = { ...headers };
const hasAuthHeader = Boolean(resolvedHeaders.Authorization || resolvedHeaders.authorization);
if (apiKey && !hasAuthHeader) {
resolvedHeaders.Authorization = `Bearer ${apiKey}`;
}
const result = await apiCallApi.request({
method: 'GET',
url: endpoint,
header: Object.keys(resolvedHeaders).length ? resolvedHeaders : undefined
});
if (result.statusCode < 200 || result.statusCode >= 300) {
throw new Error(getApiCallErrorMessage(result));
}
const payload = result.body ?? result.bodyText;
return normalizeModelList(payload, { dedupe: true });
}
};

View File

@@ -8,6 +8,7 @@ import { persist } from 'zustand/middleware';
import type { Language } from '@/types';
import { STORAGE_KEY_LANGUAGE } from '@/utils/constants';
import i18n from '@/i18n';
import { getInitialLanguage } from '@/utils/language';
interface LanguageState {
language: Language;
@@ -18,7 +19,7 @@ interface LanguageState {
export const useLanguageStore = create<LanguageState>()(
persist(
(set, get) => ({
language: 'zh-CN',
language: getInitialLanguage(),
setLanguage: (language) => {
// 切换 i18next 语言

View File

@@ -350,6 +350,32 @@ textarea {
justify-content: center;
z-index: $z-modal;
padding: $spacing-lg;
&.modal-overlay-entering {
animation: modal-overlay-fade-in 0.25s ease-out forwards;
}
&.modal-overlay-closing {
animation: modal-overlay-fade-out 0.35s ease-in forwards;
}
}
@keyframes modal-overlay-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modal-overlay-fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.modal {
@@ -361,34 +387,58 @@ textarea {
overflow: hidden;
display: flex;
flex-direction: column;
}
position: relative;
// 关闭按钮中心位置: right 12px + 16px = 28px, top 12px + 16px = 28px
transform-origin: calc(100% - 28px) 28px;
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: $spacing-md $spacing-lg;
border-bottom: 1px solid var(--border-color);
.modal-title {
font-weight: 700;
font-size: 18px;
color: var(--text-primary);
&.modal-entering {
animation: modal-scale-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
.modal-close {
display: inline-flex;
align-items: center;
justify-content: center;
&.modal-closing {
animation: modal-collapse-to-close 0.35s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
}
@keyframes modal-scale-in {
from {
opacity: 0;
transform: scale(0.85) translateY(20px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes modal-collapse-to-close {
0% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0);
}
}
.modal-close-floating {
position: absolute;
top: 12px;
right: 12px;
width: 32px;
height: 32px;
padding: 0;
background: transparent;
border: none;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
cursor: pointer;
border-radius: $radius-md;
transition: color 0.15s ease, background-color 0.15s ease;
border-radius: $radius-full;
display: inline-flex;
align-items: center;
justify-content: center;
transition: color 0.15s ease, background-color 0.15s ease, transform 0.15s ease;
z-index: 10;
svg {
display: block;
@@ -396,8 +446,25 @@ textarea {
&:hover {
color: var(--text-primary);
background: var(--bg-secondary);
background: var(--bg-tertiary);
transform: scale(1.1);
}
&:active {
transform: scale(0.95);
}
}
.modal-header {
display: flex;
align-items: center;
padding: $spacing-md $spacing-lg;
border-bottom: 1px solid var(--border-color);
.modal-title {
font-weight: 700;
font-size: 18px;
color: var(--text-primary);
}
}

View File

@@ -35,6 +35,9 @@
width: 100%;
@media (max-width: $breakpoint-mobile) {
position: fixed;
left: 0;
right: 0;
padding: $spacing-sm $spacing-md;
gap: $spacing-sm;
}
@@ -242,6 +245,7 @@
height: auto;
min-height: calc(100vh - var(--header-height));
overflow: visible;
padding-top: var(--header-height);
@supports (min-height: 100dvh) {
min-height: calc(100dvh - var(--header-height));
@@ -393,6 +397,13 @@
font-size: 14px;
flex-wrap: wrap;
gap: $spacing-sm;
.footer-version {
user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
-webkit-touch-callout: none;
}
}
.login-page {
@@ -420,6 +431,7 @@
display: flex;
flex-direction: column;
gap: $spacing-sm;
text-align: center;
.title {
font-size: 22px;
@@ -432,6 +444,18 @@
}
}
.login-title-row {
display: flex;
align-items: center;
justify-content: center;
gap: $spacing-sm;
flex-wrap: wrap;
}
.login-language-btn {
white-space: nowrap;
}
.connection-box {
background: var(--bg-secondary);
border: 1px dashed var(--border-color);

42
src/utils/language.ts Normal file
View File

@@ -0,0 +1,42 @@
import type { Language } from '@/types';
import { STORAGE_KEY_LANGUAGE } from '@/utils/constants';
const parseStoredLanguage = (value: string): Language | null => {
try {
const parsed = JSON.parse(value);
const candidate = parsed?.state?.language ?? parsed?.language ?? parsed;
if (candidate === 'zh-CN' || candidate === 'en') {
return candidate;
}
} catch {
if (value === 'zh-CN' || value === 'en') {
return value;
}
}
return null;
};
const getStoredLanguage = (): Language | null => {
if (typeof window === 'undefined') {
return null;
}
try {
const stored = localStorage.getItem(STORAGE_KEY_LANGUAGE);
if (!stored) {
return null;
}
return parseStoredLanguage(stored);
} catch {
return null;
}
};
const getBrowserLanguage = (): Language => {
if (typeof navigator === 'undefined') {
return 'zh-CN';
}
const raw = navigator.languages?.[0] || navigator.language || 'zh-CN';
return raw.toLowerCase().startsWith('zh') ? 'zh-CN' : 'en';
};
export const getInitialLanguage = (): Language => getStoredLanguage() ?? getBrowserLanguage();