Compare commits

..

19 Commits

Author SHA1 Message Date
Supra4E8C
3a66dc225d feat(auth): add remember-password login and clear local auth data card 2025-12-31 20:04:32 +08:00
Supra4E8C
eadfd7a957 feat(quota): group Gemini CLI buckets and refine Gemini quota groups 2025-12-30 21:48:12 +08:00
Supra4E8C
f739e0b372 style(config): double editor height 2025-12-30 18:29:47 +08:00
Supra4E8C
23fb88e5fd feat(quota): add zustand store for quota state caching 2025-12-30 18:29:28 +08:00
Supra4E8C
49b9259452 feat(quota): add quota page and update i18n 2025-12-30 14:13:04 +08:00
Supra4E8C
4e26b6c92d feat(auth-files): add Gemini CLI quota card and API call 2025-12-30 12:18:20 +08:00
Supra4E8C
215ce61b48 fix: error display 2025-12-30 00:17:51 +08:00
Supra4E8C
a48e06a28c fix(auth-files): use account id for codex quota and show remaining 2025-12-29 23:13:55 +08:00
Supra4E8C
8a59ab73a1 chore(i18n): update antigravity refresh label 2025-12-29 12:33:04 +08:00
Supra4E8C
66d58288b4 fix(auth): update antigravity fetchAvailableModels endpoint 2025-12-29 12:09:37 +08:00
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
30 changed files with 3246 additions and 158 deletions

2
.gitignore vendored
View File

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

View File

@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <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" /> <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

@@ -7,6 +7,7 @@ import { ApiKeysPage } from '@/pages/ApiKeysPage';
import { AiProvidersPage } from '@/pages/AiProvidersPage'; import { AiProvidersPage } from '@/pages/AiProvidersPage';
import { AuthFilesPage } from '@/pages/AuthFilesPage'; import { AuthFilesPage } from '@/pages/AuthFilesPage';
import { OAuthPage } from '@/pages/OAuthPage'; import { OAuthPage } from '@/pages/OAuthPage';
import { QuotaPage } from '@/pages/QuotaPage';
import { UsagePage } from '@/pages/UsagePage'; import { UsagePage } from '@/pages/UsagePage';
import { ConfigPage } from '@/pages/ConfigPage'; import { ConfigPage } from '@/pages/ConfigPage';
import { LogsPage } from '@/pages/LogsPage'; import { LogsPage } from '@/pages/LogsPage';
@@ -43,6 +44,10 @@ function App() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 仅用于首屏同步 i18n 语言 }, []); // 仅用于首屏同步 i18n 语言
useEffect(() => {
document.documentElement.lang = language;
}, [language]);
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
setSplashReadyToFade(true); setSplashReadyToFade(true);
@@ -84,6 +89,7 @@ function App() {
<Route path="ai-providers" element={<AiProvidersPage />} /> <Route path="ai-providers" element={<AiProvidersPage />} />
<Route path="auth-files" element={<AuthFilesPage />} /> <Route path="auth-files" element={<AuthFilesPage />} />
<Route path="oauth" element={<OAuthPage />} /> <Route path="oauth" element={<OAuthPage />} />
<Route path="quota" element={<QuotaPage />} />
<Route path="usage" element={<UsagePage />} /> <Route path="usage" element={<UsagePage />} />
<Route path="config" element={<ConfigPage />} /> <Route path="config" element={<ConfigPage />} />
<Route path="logs" element={<LogsPage />} /> <Route path="logs" element={<LogsPage />} />

View File

@@ -23,6 +23,7 @@ import {
IconSettings, IconSettings,
IconShield, IconShield,
IconSlidersHorizontal, IconSlidersHorizontal,
IconTimer,
} from '@/components/ui/icons'; } from '@/components/ui/icons';
import { INLINE_LOGO_JPEG } from '@/assets/logoInline'; import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
import { import {
@@ -41,6 +42,7 @@ const sidebarIcons: Record<string, ReactNode> = {
aiProviders: <IconBot size={18} />, aiProviders: <IconBot size={18} />,
authFiles: <IconFileText size={18} />, authFiles: <IconFileText size={18} />,
oauth: <IconShield size={18} />, oauth: <IconShield size={18} />,
quota: <IconTimer size={18} />,
usage: <IconChartLine size={18} />, usage: <IconChartLine size={18} />,
config: <IconSettings size={18} />, config: <IconSettings size={18} />,
logs: <IconScrollText size={18} />, logs: <IconScrollText size={18} />,
@@ -355,6 +357,7 @@ export function MainLayout() {
{ path: '/ai-providers', label: t('nav.ai_providers'), icon: sidebarIcons.aiProviders }, { path: '/ai-providers', label: t('nav.ai_providers'), icon: sidebarIcons.aiProviders },
{ path: '/auth-files', label: t('nav.auth_files'), icon: sidebarIcons.authFiles }, { path: '/auth-files', label: t('nav.auth_files'), icon: sidebarIcons.authFiles },
{ path: '/oauth', label: t('nav.oauth', { defaultValue: 'OAuth' }), icon: sidebarIcons.oauth }, { path: '/oauth', label: t('nav.oauth', { defaultValue: 'OAuth' }), icon: sidebarIcons.oauth },
{ path: '/quota', label: t('nav.quota_management'), icon: sidebarIcons.quota },
{ path: '/usage', label: t('nav.usage_stats'), icon: sidebarIcons.usage }, { path: '/usage', label: t('nav.usage_stats'), icon: sidebarIcons.usage },
{ path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config }, { path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config },
...(config?.loggingToFile ...(config?.loggingToFile

View File

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

View File

@@ -34,6 +34,8 @@
"alias": "Alias", "alias": "Alias",
"failure": "Failure", "failure": "Failure",
"unknown_error": "Unknown error", "unknown_error": "Unknown error",
"quota_update_required": "Please update the CPA version or check for updates",
"quota_check_credential": "Please check the credential status",
"copy": "Copy", "copy": "Copy",
"custom_headers_label": "Custom Headers", "custom_headers_label": "Custom Headers",
"custom_headers_hint": "Optional HTTP headers to send with the request. Leave blank to remove.", "custom_headers_hint": "Optional HTTP headers to send with the request. Leave blank to remove.",
@@ -61,6 +63,7 @@
"custom_connection_placeholder": "Eg: https://example.com:8317", "custom_connection_placeholder": "Eg: https://example.com:8317",
"custom_connection_hint": "By default the current URL is used. Override it here if needed.", "custom_connection_hint": "By default the current URL is used. Override it here if needed.",
"use_current_address": "Use Current URL", "use_current_address": "Use Current URL",
"remember_password_label": "Remember password",
"management_key_label": "Management Key:", "management_key_label": "Management Key:",
"management_key_placeholder": "Enter the management key", "management_key_placeholder": "Enter the management key",
"connect_button": "Connect", "connect_button": "Connect",
@@ -88,6 +91,7 @@
"ai_providers": "AI Providers", "ai_providers": "AI Providers",
"auth_files": "Auth Files", "auth_files": "Auth Files",
"oauth": "OAuth Login", "oauth": "OAuth Login",
"quota_management": "Quota Management",
"usage_stats": "Usage Statistics", "usage_stats": "Usage Statistics",
"config_management": "Config Management", "config_management": "Config Management",
"logs": "Logs Viewer", "logs": "Logs Viewer",
@@ -357,6 +361,53 @@
"models_excluded_badge": "Excluded", "models_excluded_badge": "Excluded",
"models_excluded_hint": "This model is excluded by OAuth" "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 Button.",
"loading": "Loading quota...",
"load_failed": "Failed to load quota: {{message}}",
"missing_auth_index": "Auth file missing auth_index",
"empty_models": "No quota data available",
"refresh_button": "Refresh Quota",
"fetch_all": "Fetch All"
},
"codex_quota": {
"title": "Codex Quota",
"empty_title": "No Codex Auth Files",
"empty_desc": "Upload a Codex credential to view quota.",
"idle": "Not loaded. Click Refresh Button.",
"loading": "Loading quota...",
"load_failed": "Failed to load quota: {{message}}",
"missing_auth_index": "Auth file missing auth_index",
"missing_account_id": "Codex credential missing ChatGPT account ID",
"empty_windows": "No quota data available",
"no_access": "This credential has no Codex access (plan: free).",
"refresh_button": "Refresh Quota",
"fetch_all": "Fetch All",
"primary_window": "5-hour limit",
"secondary_window": "Weekly limit",
"code_review_window": "Code review limit",
"plan_label": "Plan",
"plan_plus": "Plus",
"plan_team": "Team",
"plan_free": "Free"
},
"gemini_cli_quota": {
"title": "Gemini CLI Quota",
"empty_title": "No Gemini CLI Auth Files",
"empty_desc": "Upload a Gemini CLI credential to view remaining quota.",
"idle": "Not loaded. Click Refresh Button.",
"loading": "Loading quota...",
"load_failed": "Failed to load quota: {{message}}",
"missing_auth_index": "Auth file missing auth_index",
"missing_project_id": "Gemini CLI credential missing project ID",
"empty_buckets": "No quota data available",
"refresh_button": "Refresh Quota",
"fetch_all": "Fetch All",
"remaining_amount": "Remaining {{count}}"
},
"vertex_import": { "vertex_import": {
"title": "Vertex JSON Login", "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.", "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.",
@@ -655,6 +706,11 @@
"search_prev": "Previous", "search_prev": "Previous",
"search_next": "Next" "search_next": "Next"
}, },
"quota_management": {
"title": "Quota Management",
"description": "Monitor OAuth quota status for Antigravity, Codex, and Gemini CLI credentials.",
"refresh_files": "Refresh auth files"
},
"system_info": { "system_info": {
"title": "Management Center Info", "title": "Management Center Info",
"connection_status_title": "Connection Status", "connection_status_title": "Connection Status",
@@ -690,7 +746,11 @@
"link_webui_repo": "WebUI Repository", "link_webui_repo": "WebUI Repository",
"link_webui_repo_desc": "Management Center frontend source code", "link_webui_repo_desc": "Management Center frontend source code",
"link_docs": "Documentation", "link_docs": "Documentation",
"link_docs_desc": "Usage tutorials and configuration guides" "link_docs_desc": "Usage tutorials and configuration guides",
"clear_login_title": "Local Login Data",
"clear_login_desc": "Clear locally saved login data and sign out. Usage stats pricing settings will remain untouched.",
"clear_login_button": "Clear login data",
"clear_login_confirm": "Clear local login data and sign out now?"
}, },
"notification": { "notification": {
"debug_updated": "Debug settings updated", "debug_updated": "Debug settings updated",
@@ -703,6 +763,7 @@
"logging_to_file_updated": "Logging settings updated", "logging_to_file_updated": "Logging settings updated",
"request_log_updated": "Request logging setting updated", "request_log_updated": "Request logging setting updated",
"ws_auth_updated": "WebSocket authentication setting updated", "ws_auth_updated": "WebSocket authentication setting updated",
"login_storage_cleared": "Local login data cleared",
"api_key_added": "API key added successfully", "api_key_added": "API key added successfully",
"api_key_updated": "API key updated successfully", "api_key_updated": "API key updated successfully",
"api_key_deleted": "API key deleted successfully", "api_key_deleted": "API key deleted successfully",

View File

@@ -34,6 +34,8 @@
"alias": "别名", "alias": "别名",
"failure": "失败", "failure": "失败",
"unknown_error": "未知错误", "unknown_error": "未知错误",
"quota_update_required": "请更新 CPA 版本或检查更新",
"quota_check_credential": "请检查凭证状态",
"copy": "复制", "copy": "复制",
"custom_headers_label": "自定义请求头", "custom_headers_label": "自定义请求头",
"custom_headers_hint": "可选,设置需要附带到请求中的 HTTP 头,名称和值均不能为空。", "custom_headers_hint": "可选,设置需要附带到请求中的 HTTP 头,名称和值均不能为空。",
@@ -61,6 +63,7 @@
"custom_connection_placeholder": "例如: https://example.com:8317", "custom_connection_placeholder": "例如: https://example.com:8317",
"custom_connection_hint": "默认使用当前访问地址,若需要可手动输入其他地址。", "custom_connection_hint": "默认使用当前访问地址,若需要可手动输入其他地址。",
"use_current_address": "使用当前地址", "use_current_address": "使用当前地址",
"remember_password_label": "记住密码",
"management_key_label": "管理密钥:", "management_key_label": "管理密钥:",
"management_key_placeholder": "请输入管理密钥", "management_key_placeholder": "请输入管理密钥",
"connect_button": "连接", "connect_button": "连接",
@@ -88,6 +91,7 @@
"ai_providers": "AI 提供商", "ai_providers": "AI 提供商",
"auth_files": "认证文件", "auth_files": "认证文件",
"oauth": "OAuth 登录", "oauth": "OAuth 登录",
"quota_management": "配额管理",
"usage_stats": "使用统计", "usage_stats": "使用统计",
"config_management": "配置管理", "config_management": "配置管理",
"logs": "日志查看", "logs": "日志查看",
@@ -357,6 +361,53 @@
"models_excluded_badge": "已排除", "models_excluded_badge": "已排除",
"models_excluded_hint": "此模型已被 OAuth 排除" "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": "暂无额度数据",
"refresh_button": "刷新额度",
"fetch_all": "获取全部"
},
"codex_quota": {
"title": "Codex 额度",
"empty_title": "暂无 Codex 认证",
"empty_desc": "上传 Codex 认证文件后即可查看额度。",
"idle": "尚未加载额度,请点击刷新按钮。",
"loading": "正在加载额度...",
"load_failed": "额度获取失败:{{message}}",
"missing_auth_index": "认证文件缺少 auth_index",
"missing_account_id": "Codex 凭证缺少 ChatGPT 账号 ID",
"empty_windows": "暂无额度数据",
"no_access": "该凭证已无 Codex 访问权限free。",
"refresh_button": "刷新额度",
"fetch_all": "获取全部",
"primary_window": "5 小时限额",
"secondary_window": "周限额",
"code_review_window": "代码审查限额",
"plan_label": "套餐",
"plan_plus": "Plus",
"plan_team": "Team",
"plan_free": "Free"
},
"gemini_cli_quota": {
"title": "Gemini CLI 额度",
"empty_title": "暂无 Gemini CLI 认证",
"empty_desc": "上传 Gemini CLI 认证文件后即可查看额度。",
"idle": "尚未加载额度,请点击刷新按钮。",
"loading": "正在加载额度...",
"load_failed": "额度获取失败:{{message}}",
"missing_auth_index": "认证文件缺少 auth_index",
"missing_project_id": "Gemini CLI 凭证缺少 Project ID",
"empty_buckets": "暂无额度数据",
"refresh_button": "刷新额度",
"fetch_all": "获取全部",
"remaining_amount": "剩余 {{count}}"
},
"vertex_import": { "vertex_import": {
"title": "Vertex JSON 登录", "title": "Vertex JSON 登录",
"description": "上传 Google 服务账号 JSON使用 CLI vertex-import 同步规则写入 auth-dir/vertex-<project>.json。", "description": "上传 Google 服务账号 JSON使用 CLI vertex-import 同步规则写入 auth-dir/vertex-<project>.json。",
@@ -655,6 +706,11 @@
"search_prev": "上一个", "search_prev": "上一个",
"search_next": "下一个" "search_next": "下一个"
}, },
"quota_management": {
"title": "配额管理",
"description": "集中查看 OAuth 额度与剩余情况",
"refresh_files": "刷新认证文件"
},
"system_info": { "system_info": {
"title": "管理中心信息", "title": "管理中心信息",
"connection_status_title": "连接状态", "connection_status_title": "连接状态",
@@ -690,7 +746,11 @@
"link_webui_repo": "WebUI 仓库", "link_webui_repo": "WebUI 仓库",
"link_webui_repo_desc": "管理中心前端界面源代码", "link_webui_repo_desc": "管理中心前端界面源代码",
"link_docs": "使用教程", "link_docs": "使用教程",
"link_docs_desc": "配置指南和使用说明" "link_docs_desc": "配置指南和使用说明",
"clear_login_title": "本地登录信息",
"clear_login_desc": "清理本地保存的登录信息并退出登录,不会影响使用统计中的价格设置。",
"clear_login_button": "清理登录信息",
"clear_login_confirm": "确认清理本地登录信息并退出登录?"
}, },
"notification": { "notification": {
"debug_updated": "调试设置已更新", "debug_updated": "调试设置已更新",
@@ -703,6 +763,7 @@
"logging_to_file_updated": "日志记录设置已更新", "logging_to_file_updated": "日志记录设置已更新",
"request_log_updated": "请求日志设置已更新", "request_log_updated": "请求日志设置已更新",
"ws_auth_updated": "WebSocket 鉴权设置已更新", "ws_auth_updated": "WebSocket 鉴权设置已更新",
"login_storage_cleared": "本地登录信息已清理",
"api_key_added": "API密钥添加成功", "api_key_added": "API密钥添加成功",
"api_key_updated": "API密钥更新成功", "api_key_updated": "API密钥更新成功",
"api_key_deleted": "API密钥删除成功", "api_key_deleted": "API密钥删除成功",

View File

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

View File

@@ -162,6 +162,272 @@
} }
} }
.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;
}
}
.codexGrid {
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;
}
}
.geminiCliGrid {
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;
}
}
.codexControls {
display: flex;
gap: $spacing-md;
flex-wrap: wrap;
align-items: flex-end;
margin-bottom: $spacing-md;
}
.codexControl {
display: flex;
flex-direction: column;
gap: 4px;
label {
font-size: 12px;
color: var(--text-secondary);
font-weight: 500;
white-space: nowrap;
}
}
.geminiCliControls {
display: flex;
gap: $spacing-md;
flex-wrap: wrap;
align-items: flex-end;
margin-bottom: $spacing-md;
}
.geminiCliControl {
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)
);
}
.codexCard {
background-image: linear-gradient(
180deg,
rgba(255, 243, 224, 0.18),
rgba(255, 243, 224, 0)
);
}
.geminiCliCard {
background-image: linear-gradient(
180deg,
rgba(231, 239, 255, 0.2),
rgba(231, 239, 255, 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);
}
.quotaAmount {
color: var(--text-secondary);
}
.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;
}
.quotaWarning {
font-size: 12px;
color: var(--warning-color, #f59e0b);
background-color: rgba(245, 158, 11, 0.12);
border: 1px solid var(--warning-color, #f59e0b);
border-radius: $radius-sm;
padding: $spacing-xs $spacing-sm;
}
.codexPlan {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-secondary);
}
.codexPlanLabel {
color: var(--text-tertiary);
}
.codexPlanValue {
font-weight: 600;
color: var(--text-primary);
text-transform: capitalize;
}
// 单个认证文件卡片 // 单个认证文件卡片
.fileCard { .fileCard {
background-color: var(--bg-primary); background-color: var(--bg-primary);

View File

@@ -9,7 +9,7 @@ import { Modal } from '@/components/ui/Modal';
import { EmptyState } from '@/components/ui/EmptyState'; import { EmptyState } from '@/components/ui/EmptyState';
import { IconBot, IconDownload, IconInfo, IconTrash2 } from '@/components/ui/icons'; import { IconBot, IconDownload, IconInfo, IconTrash2 } from '@/components/ui/icons';
import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores'; import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
import { authFilesApi, usageApi } from '@/services/api'; import { authFilesApi, usageApi } from '@/services/api';
import { apiClient } from '@/services/api/client'; import { apiClient } from '@/services/api/client';
import type { AuthFileItem } from '@/types'; import type { AuthFileItem } from '@/types';
import type { KeyStats, KeyStatBucket, UsageDetail } from '@/utils/usage'; import type { KeyStats, KeyStatBucket, UsageDetail } from '@/utils/usage';
@@ -83,22 +83,21 @@ interface ExcludedFormState {
provider: string; provider: string;
modelsText: string; modelsText: string;
} }
// 标准化 auth_index 值(与 usage.ts 中的 normalizeAuthIndex 保持一致)
// 标准化 auth_index 值(与 usage.ts 中的 normalizeAuthIndex 保持一致) function normalizeAuthIndexValue(value: unknown): string | null {
function normalizeAuthIndexValue(value: unknown): string | null { if (typeof value === 'number' && Number.isFinite(value)) {
if (typeof value === 'number' && Number.isFinite(value)) { return value.toString();
return value.toString(); }
} if (typeof value === 'string') {
if (typeof value === 'string') { const trimmed = value.trim();
const trimmed = value.trim(); return trimmed ? trimmed : null;
return trimmed ? trimmed : null; }
} return null;
return null; }
}
function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean {
function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean { const raw = file['runtime_only'] ?? file.runtimeOnly;
const raw = file['runtime_only'] ?? file.runtimeOnly; if (typeof raw === 'boolean') return raw;
if (typeof raw === 'boolean') return raw;
if (typeof raw === 'string') return raw.trim().toLowerCase() === 'true'; if (typeof raw === 'string') return raw.trim().toLowerCase() === 'true';
return false; return false;
} }
@@ -151,15 +150,15 @@ export function AuthFilesPage() {
const [files, setFiles] = useState<AuthFileItem[]>([]); const [files, setFiles] = useState<AuthFileItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [filter, setFilter] = useState<'all' | string>('all'); const [filter, setFilter] = useState<'all' | string>('all');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(9); const [pageSize, setPageSize] = useState(9);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [deleting, setDeleting] = useState<string | null>(null); const [deleting, setDeleting] = useState<string | null>(null);
const [deletingAll, setDeletingAll] = useState(false); const [deletingAll, setDeletingAll] = useState(false);
const [keyStats, setKeyStats] = useState<KeyStats>({ bySource: {}, byAuthIndex: {} }); const [keyStats, setKeyStats] = useState<KeyStats>({ bySource: {}, byAuthIndex: {} });
const [usageDetails, setUsageDetails] = useState<UsageDetail[]>([]); const [usageDetails, setUsageDetails] = useState<UsageDetail[]>([]);
// 详情弹窗相关 // 详情弹窗相关
const [detailModalOpen, setDetailModalOpen] = useState(false); const [detailModalOpen, setDetailModalOpen] = useState(false);
@@ -177,11 +176,11 @@ export function AuthFilesPage() {
const [excluded, setExcluded] = useState<Record<string, string[]>>({}); const [excluded, setExcluded] = useState<Record<string, string[]>>({});
const [excludedError, setExcludedError] = useState<'unsupported' | null>(null); const [excludedError, setExcludedError] = useState<'unsupported' | null>(null);
const [excludedModalOpen, setExcludedModalOpen] = useState(false); const [excludedModalOpen, setExcludedModalOpen] = useState(false);
const [excludedForm, setExcludedForm] = useState<ExcludedFormState>({ provider: '', modelsText: '' }); const [excludedForm, setExcludedForm] = useState<ExcludedFormState>({ provider: '', modelsText: '' });
const [savingExcluded, setSavingExcluded] = useState(false); const [savingExcluded, setSavingExcluded] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
const loadingKeyStatsRef = useRef(false); const loadingKeyStatsRef = useRef(false);
const excludedUnsupportedRef = useRef(false); const excludedUnsupportedRef = useRef(false);
const disableControls = connectionStatus !== 'connected'; const disableControls = connectionStatus !== 'connected';
@@ -234,11 +233,11 @@ export function AuthFilesPage() {
}, []); }, []);
// 加载 OAuth 排除列表 // 加载 OAuth 排除列表
const loadExcluded = useCallback(async () => { const loadExcluded = useCallback(async () => {
try { try {
const res = await authFilesApi.getOauthExcludedModels(); const res = await authFilesApi.getOauthExcludedModels();
excludedUnsupportedRef.current = false; excludedUnsupportedRef.current = false;
setExcluded(res || {}); setExcluded(res || {});
setExcludedError(null); setExcludedError(null);
} catch (err: unknown) { } catch (err: unknown) {
const status = const status =
@@ -255,30 +254,31 @@ export function AuthFilesPage() {
} }
return; return;
} }
// 静默失败 // 静默失败
} }
}, [showNotification, t]); }, [showNotification, t]);
useEffect(() => {
loadFiles();
loadKeyStats();
loadExcluded();
}, [loadFiles, loadKeyStats, loadExcluded]);
useEffect(() => { // 定时刷新状态数据每240秒
loadFiles(); useInterval(loadKeyStats, 240_000);
loadKeyStats();
loadExcluded();
}, [loadFiles, loadKeyStats, loadExcluded]);
// 定时刷新状态数据每240秒
useInterval(loadKeyStats, 240_000);
// 提取所有存在的类型 // 提取所有存在的类型
const existingTypes = useMemo(() => { const existingTypes = useMemo(() => {
const types = new Set<string>(['all']); const types = new Set<string>(['all']);
files.forEach((file) => { files.forEach((file) => {
if (file.type) { if (file.type) {
types.add(file.type); types.add(file.type);
} }
}); });
return Array.from(types); return Array.from(types);
}, [files]); }, [files]);
const excludedProviderLookup = useMemo(() => { const excludedProviderLookup = useMemo(() => {
const lookup = new Map<string, string>(); const lookup = new Map<string, string>();
Object.keys(excluded).forEach((provider) => { Object.keys(excluded).forEach((provider) => {
@@ -556,10 +556,10 @@ export function AuthFilesPage() {
}; };
// 获取类型颜色 // 获取类型颜色
const getTypeColor = (type: string): ThemeColors => { const getTypeColor = (type: string): ThemeColors => {
const set = TYPE_COLORS[type] || TYPE_COLORS.unknown; const set = TYPE_COLORS[type] || TYPE_COLORS.unknown;
return resolvedTheme === 'dark' && set.dark ? set.dark : set.light; return resolvedTheme === 'dark' && set.dark ? set.dark : set.light;
}; };
// OAuth 排除相关方法 // OAuth 排除相关方法
const openExcludedModal = (provider?: string) => { const openExcludedModal = (provider?: string) => {
@@ -704,10 +704,10 @@ export function AuthFilesPage() {
}; };
// 渲染单个认证文件卡片 // 渲染单个认证文件卡片
const renderFileCard = (item: AuthFileItem) => { const renderFileCard = (item: AuthFileItem) => {
const fileStats = resolveAuthFileStats(item, keyStats); const fileStats = resolveAuthFileStats(item, keyStats);
const isRuntimeOnly = isRuntimeOnlyAuthFile(item); const isRuntimeOnly = isRuntimeOnlyAuthFile(item);
const typeColor = getTypeColor(item.type || 'unknown'); const typeColor = getTypeColor(item.type || 'unknown');
return ( return (
<div key={item.name} className={styles.fileCard}> <div key={item.name} className={styles.fileCard}>
@@ -794,12 +794,12 @@ export function AuthFilesPage() {
</> </>
)} )}
</div> </div>
</div> </div>
); );
}; };
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.pageHeader}> <div className={styles.pageHeader}>
<h1 className={styles.pageTitle}>{t('auth_files.title')}</h1> <h1 className={styles.pageTitle}>{t('auth_files.title')}</h1>
<p className={styles.description}>{t('auth_files.description')}</p> <p className={styles.description}>{t('auth_files.description')}</p>
@@ -918,13 +918,13 @@ export function AuthFilesPage() {
</Button> </Button>
</div> </div>
)} )}
</Card> </Card>
{/* OAuth 排除列表卡片 */} {/* OAuth 排除列表卡片 */}
<Card <Card
title={t('oauth_excluded.title')} title={t('oauth_excluded.title')}
extra={ extra={
<Button <Button
size="sm" size="sm"
onClick={() => openExcludedModal()} onClick={() => openExcludedModal()}
disabled={disableControls || excludedError === 'unsupported'} disabled={disableControls || excludedError === 'unsupported'}

View File

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

View File

@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { IconEye, IconEyeOff } from '@/components/ui/icons'; import { IconEye, IconEyeOff } from '@/components/ui/icons';
import { useAuthStore, useNotificationStore } from '@/stores'; import { useAuthStore, useLanguageStore, useNotificationStore } from '@/stores';
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection'; import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
export function LoginPage() { export function LoginPage() {
@@ -12,21 +12,26 @@ export function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { showNotification } = useNotificationStore(); const { showNotification } = useNotificationStore();
const language = useLanguageStore((state) => state.language);
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const login = useAuthStore((state) => state.login); const login = useAuthStore((state) => state.login);
const restoreSession = useAuthStore((state) => state.restoreSession); const restoreSession = useAuthStore((state) => state.restoreSession);
const storedBase = useAuthStore((state) => state.apiBase); const storedBase = useAuthStore((state) => state.apiBase);
const storedKey = useAuthStore((state) => state.managementKey); const storedKey = useAuthStore((state) => state.managementKey);
const storedRememberPassword = useAuthStore((state) => state.rememberPassword);
const [apiBase, setApiBase] = useState(''); const [apiBase, setApiBase] = useState('');
const [managementKey, setManagementKey] = useState(''); const [managementKey, setManagementKey] = useState('');
const [showCustomBase, setShowCustomBase] = useState(false); const [showCustomBase, setShowCustomBase] = useState(false);
const [showKey, setShowKey] = useState(false); const [showKey, setShowKey] = useState(false);
const [rememberPassword, setRememberPassword] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [autoLoading, setAutoLoading] = useState(true); const [autoLoading, setAutoLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const detectedBase = useMemo(() => detectApiBaseFromLocation(), []); const detectedBase = useMemo(() => detectApiBaseFromLocation(), []);
const nextLanguageLabel = language === 'zh-CN' ? t('language.english') : t('language.chinese');
useEffect(() => { useEffect(() => {
const init = async () => { const init = async () => {
@@ -35,6 +40,7 @@ export function LoginPage() {
if (!autoLoggedIn) { if (!autoLoggedIn) {
setApiBase(storedBase || detectedBase); setApiBase(storedBase || detectedBase);
setManagementKey(storedKey || ''); setManagementKey(storedKey || '');
setRememberPassword(storedRememberPassword || Boolean(storedKey));
} }
} finally { } finally {
setAutoLoading(false); setAutoLoading(false);
@@ -42,17 +48,13 @@ export function LoginPage() {
}; };
init(); init();
}, [detectedBase, restoreSession, storedBase, storedKey]); }, [detectedBase, restoreSession, storedBase, storedKey, storedRememberPassword]);
if (isAuthenticated) { if (isAuthenticated) {
const redirect = (location.state as any)?.from?.pathname || '/'; const redirect = (location.state as any)?.from?.pathname || '/';
return <Navigate to={redirect} replace />; return <Navigate to={redirect} replace />;
} }
const handleUseCurrent = () => {
setApiBase(detectedBase);
};
const handleSubmit = async () => { const handleSubmit = async () => {
if (!managementKey.trim()) { if (!managementKey.trim()) {
setError(t('login.error_required')); setError(t('login.error_required'));
@@ -63,7 +65,11 @@ export function LoginPage() {
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
await login({ apiBase: baseToUse, managementKey: managementKey.trim() }); await login({
apiBase: baseToUse,
managementKey: managementKey.trim(),
rememberPassword
});
showNotification(t('common.connected_status'), 'success'); showNotification(t('common.connected_status'), 'success');
navigate('/', { replace: true }); navigate('/', { replace: true });
} catch (err: any) { } catch (err: any) {
@@ -79,7 +85,20 @@ export function LoginPage() {
<div className="login-page"> <div className="login-page">
<div className="login-card"> <div className="login-card">
<div className="login-header"> <div className="login-header">
<div className="title">{t('title.login')}</div> <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 className="subtitle">{t('login.subtitle')}</div>
</div> </div>
@@ -136,15 +155,20 @@ export function LoginPage() {
} }
/> />
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}> <div className="toggle-advanced">
<Button variant="secondary" onClick={handleUseCurrent}> <input
{t('login.use_current_address')} id="remember-password-toggle"
</Button> type="checkbox"
<Button fullWidth onClick={handleSubmit} loading={loading}> checked={rememberPassword}
{loading ? t('login.submitting') : t('login.submit_button')} onChange={(e) => setRememberPassword(e.target.checked)}
</Button> />
<label htmlFor="remember-password-toggle">{t('login.remember_password_label')}</label>
</div> </div>
<Button fullWidth onClick={handleSubmit} loading={loading}>
{loading ? t('login.submitting') : t('login.submit_button')}
</Button>
{error && <div className="error-box">{error}</div>} {error && <div className="error-box">{error}</div>}
{autoLoading && ( {autoLoading && (

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 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') => { const getIcon = (icon: string | { light: string; dark: string }, theme: 'light' | 'dark') => {
return typeof icon === 'string' ? icon : icon[theme]; return typeof icon === 'string' ? icon : icon[theme];
@@ -105,12 +108,15 @@ export function OAuthPage() {
const res = await oauthApi.getAuthStatus(state); const res = await oauthApi.getAuthStatus(state);
if (res.status === 'ok') { if (res.status === 'ok') {
updateProviderState(provider, { status: 'success', polling: false }); 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); window.clearInterval(timer);
delete timers.current[provider]; delete timers.current[provider];
} else if (res.status === 'error') { } else if (res.status === 'error') {
updateProviderState(provider, { status: 'error', error: res.error, polling: false }); 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); window.clearInterval(timer);
delete timers.current[provider]; delete timers.current[provider];
} }
@@ -153,7 +159,7 @@ export function OAuthPage() {
} }
} catch (err: any) { } catch (err: any) {
updateProviderState(provider, { status: 'error', error: err?.message, polling: false }); 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.authUrlValue}>{state.url}</div>
<div className={styles.authUrlActions}> <div className={styles.authUrlActions}>
<Button variant="secondary" size="sm" onClick={() => copyLink(state.url!)}> <Button variant="secondary" size="sm" onClick={() => copyLink(state.url!)}>
{t('auth_login.codex_copy_link')} {t(getAuthKey(provider.id, 'copy_link'))}
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
onClick={() => window.open(state.url, '_blank', 'noopener,noreferrer')} onClick={() => window.open(state.url, '_blank', 'noopener,noreferrer')}
> >
{t('auth_login.codex_open_link')} {t(getAuthKey(provider.id, 'open_link'))}
</Button> </Button>
</div> </div>
</div> </div>
@@ -399,10 +405,10 @@ export function OAuthPage() {
{state.status && state.status !== 'idle' && ( {state.status && state.status !== 'idle' && (
<div className="status-badge" style={{ marginTop: 8 }}> <div className="status-badge" style={{ marginTop: 8 }}>
{state.status === 'success' {state.status === 'success'
? t('auth_login.codex_oauth_status_success') ? t(getAuthKey(provider.id, 'oauth_status_success'))
: state.status === 'error' : state.status === 'error'
? `${t('auth_login.codex_oauth_status_error')} ${state.error || ''}` ? `${t(getAuthKey(provider.id, 'oauth_status_error'))} ${state.error || ''}`
: t('auth_login.codex_oauth_status_waiting')} : t(getAuthKey(provider.id, 'oauth_status_waiting'))}
</div> </div>
)} )}
</Card> </Card>

View File

@@ -0,0 +1,333 @@
@use '../styles/variables' as *;
@use '../styles/mixins' as *;
.container {
display: flex;
flex-direction: column;
gap: $spacing-lg;
}
.pageHeader {
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
.pageTitle {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.description {
font-size: 14px;
color: var(--text-secondary);
margin: 0;
}
.headerActions {
display: flex;
gap: $spacing-sm;
flex-wrap: wrap;
}
.errorBox {
padding: $spacing-md;
background-color: rgba(239, 68, 68, 0.1);
border: 1px solid var(--danger-color);
border-radius: $radius-md;
color: var(--danger-color);
font-size: 14px;
}
.pageSizeSelect {
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background-color: var(--bg-primary);
color: var(--text-primary);
font-size: 14px;
cursor: pointer;
height: 38px;
box-sizing: border-box;
&:focus {
outline: none;
border-color: var(--primary-color);
}
}
.statsInfo {
padding: 8px 12px;
background-color: var(--bg-secondary);
border-radius: $radius-md;
font-size: 13px;
color: var(--text-secondary);
white-space: nowrap;
height: 38px;
box-sizing: border-box;
display: flex;
align-items: center;
}
.antigravityGrid,
.codexGrid,
.geminiCliGrid {
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,
.codexControls,
.geminiCliControls {
display: flex;
gap: $spacing-md;
flex-wrap: wrap;
align-items: flex-end;
margin-bottom: $spacing-md;
}
.antigravityControl,
.codexControl,
.geminiCliControl {
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)
);
}
.codexCard {
background-image: linear-gradient(
180deg,
rgba(255, 243, 224, 0.18),
rgba(255, 243, 224, 0)
);
}
.geminiCliCard {
background-image: linear-gradient(
180deg,
rgba(231, 239, 255, 0.2),
rgba(231, 239, 255, 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);
}
.quotaAmount {
color: var(--text-secondary);
}
.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;
}
.quotaWarning {
font-size: 12px;
color: var(--warning-color, #f59e0b);
background-color: rgba(245, 158, 11, 0.12);
border: 1px solid var(--warning-color, #f59e0b);
border-radius: $radius-sm;
padding: $spacing-xs $spacing-sm;
}
.codexPlan {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-secondary);
}
.codexPlanLabel {
color: var(--text-tertiary);
}
.codexPlanValue {
font-weight: 600;
color: var(--text-primary);
text-transform: capitalize;
}
.fileCard {
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: $radius-lg;
padding: $spacing-md;
display: flex;
flex-direction: column;
gap: $spacing-sm;
transition: transform $transition-fast, box-shadow $transition-fast, border-color $transition-fast;
&:hover {
transform: translateY(-2px);
box-shadow: $shadow-md;
border-color: rgba(37, 99, 235, 0.2);
}
}
.cardHeader {
display: flex;
align-items: center;
gap: $spacing-sm;
min-height: 28px;
}
.typeBadge {
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
}
.fileName {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
word-break: break-all;
line-height: 1.4;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: $spacing-md;
margin-top: $spacing-lg;
padding-top: $spacing-md;
border-top: 1px solid var(--border-color);
}
.pageInfo {
font-size: 13px;
color: var(--text-secondary);
padding: $spacing-xs $spacing-md;
background-color: var(--bg-secondary);
border-radius: $radius-md;
}

1966
src/pages/QuotaPage.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -34,6 +34,12 @@
margin: 0 0 $spacing-md 0; margin: 0 0 $spacing-md 0;
} }
.clearLoginActions {
display: flex;
justify-content: flex-end;
align-items: center;
}
.infoGrid { .infoGrid {
display: grid; display: grid;
gap: $spacing-sm; gap: $spacing-sm;

View File

@@ -6,6 +6,7 @@ import { IconGithub, IconBookOpen, IconExternalLink, IconCode } from '@/componen
import { useAuthStore, useConfigStore, useNotificationStore, useModelsStore } from '@/stores'; import { useAuthStore, useConfigStore, useNotificationStore, useModelsStore } from '@/stores';
import { apiKeysApi } from '@/services/api/apiKeys'; import { apiKeysApi } from '@/services/api/apiKeys';
import { classifyModels } from '@/utils/models'; import { classifyModels } from '@/utils/models';
import { STORAGE_KEY_AUTH } from '@/utils/constants';
import styles from './SystemPage.module.scss'; import styles from './SystemPage.module.scss';
export function SystemPage() { export function SystemPage() {
@@ -104,6 +105,15 @@ export function SystemPage() {
} }
}; };
const handleClearLoginStorage = () => {
if (!window.confirm(t('system_info.clear_login_confirm'))) return;
auth.logout();
if (typeof localStorage === 'undefined') return;
const keysToRemove = [STORAGE_KEY_AUTH, 'isLoggedIn', 'apiBase', 'apiUrl', 'managementKey'];
keysToRemove.forEach((key) => localStorage.removeItem(key));
showNotification(t('notification.login_storage_cleared'), 'success');
};
useEffect(() => { useEffect(() => {
fetchConfig().catch(() => { fetchConfig().catch(() => {
// ignore // ignore
@@ -248,6 +258,15 @@ export function SystemPage() {
</div> </div>
)} )}
</Card> </Card>
<Card title={t('system_info.clear_login_title')}>
<p className={styles.sectionDescription}>{t('system_info.clear_login_desc')}</p>
<div className={styles.clearLoginActions}>
<Button variant="danger" onClick={handleClearLoginStorage}>
{t('system_info.clear_login_button')}
</Button>
</div>
</Card>
</div> </div>
</div> </div>
); );

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}`; 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( 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) { for (const key of keys) {
const match = normalized[key.toLowerCase()]; const match = normalizeValue(normalized[key.toLowerCase()]);
if (match) return match; if (match) return match;
} }
return null; return null;

View File

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

View File

@@ -4,6 +4,7 @@
import axios from 'axios'; import axios from 'axios';
import { normalizeModelList } from '@/utils/models'; import { normalizeModelList } from '@/utils/models';
import { apiCallApi, getApiCallErrorMessage } from './apiCall';
const normalizeBaseUrl = (baseUrl: string): string => { const normalizeBaseUrl = (baseUrl: string): string => {
let normalized = String(baseUrl || '').trim(); let normalized = String(baseUrl || '').trim();
@@ -39,5 +40,35 @@ export const modelsApi = {
}); });
const payload = response.data?.data ?? response.data?.models ?? response.data; const payload = response.data?.data ?? response.data?.models ?? response.data;
return normalizeModelList(payload, { dedupe: true }); 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,3 +8,4 @@ export { useLanguageStore } from './useLanguageStore';
export { useAuthStore } from './useAuthStore'; export { useAuthStore } from './useAuthStore';
export { useConfigStore } from './useConfigStore'; export { useConfigStore } from './useConfigStore';
export { useModelsStore } from './useModelsStore'; export { useModelsStore } from './useModelsStore';
export { useQuotaStore } from './useQuotaStore';

View File

@@ -34,6 +34,7 @@ export const useAuthStore = create<AuthStoreState>()(
isAuthenticated: false, isAuthenticated: false,
apiBase: '', apiBase: '',
managementKey: '', managementKey: '',
rememberPassword: false,
serverVersion: null, serverVersion: null,
serverBuildDate: null, serverBuildDate: null,
connectionStatus: 'disconnected', connectionStatus: 'disconnected',
@@ -52,16 +53,25 @@ export const useAuthStore = create<AuthStoreState>()(
secureStorage.getItem<string>('apiUrl', { encrypt: true }); secureStorage.getItem<string>('apiUrl', { encrypt: true });
const legacyKey = secureStorage.getItem<string>('managementKey'); const legacyKey = secureStorage.getItem<string>('managementKey');
const { apiBase, managementKey } = get(); const { apiBase, managementKey, rememberPassword } = get();
const resolvedBase = normalizeApiBase(apiBase || legacyBase || detectApiBaseFromLocation()); const resolvedBase = normalizeApiBase(apiBase || legacyBase || detectApiBaseFromLocation());
const resolvedKey = managementKey || legacyKey || ''; const resolvedKey = managementKey || legacyKey || '';
const resolvedRememberPassword = rememberPassword || Boolean(managementKey) || Boolean(legacyKey);
set({ apiBase: resolvedBase, managementKey: resolvedKey }); set({
apiBase: resolvedBase,
managementKey: resolvedKey,
rememberPassword: resolvedRememberPassword
});
apiClient.setConfig({ apiBase: resolvedBase, managementKey: resolvedKey }); apiClient.setConfig({ apiBase: resolvedBase, managementKey: resolvedKey });
if (wasLoggedIn && resolvedBase && resolvedKey) { if (wasLoggedIn && resolvedBase && resolvedKey) {
try { try {
await get().login({ apiBase: resolvedBase, managementKey: resolvedKey }); await get().login({
apiBase: resolvedBase,
managementKey: resolvedKey,
rememberPassword: resolvedRememberPassword
});
return true; return true;
} catch (error) { } catch (error) {
console.warn('Auto login failed:', error); console.warn('Auto login failed:', error);
@@ -79,6 +89,7 @@ export const useAuthStore = create<AuthStoreState>()(
login: async (credentials) => { login: async (credentials) => {
const apiBase = normalizeApiBase(credentials.apiBase); const apiBase = normalizeApiBase(credentials.apiBase);
const managementKey = credentials.managementKey.trim(); const managementKey = credentials.managementKey.trim();
const rememberPassword = credentials.rememberPassword ?? get().rememberPassword ?? false;
try { try {
set({ connectionStatus: 'connecting' }); set({ connectionStatus: 'connecting' });
@@ -97,10 +108,15 @@ export const useAuthStore = create<AuthStoreState>()(
isAuthenticated: true, isAuthenticated: true,
apiBase, apiBase,
managementKey, managementKey,
rememberPassword,
connectionStatus: 'connected', connectionStatus: 'connected',
connectionError: null connectionError: null
}); });
localStorage.setItem('isLoggedIn', 'true'); if (rememberPassword) {
localStorage.setItem('isLoggedIn', 'true');
} else {
localStorage.removeItem('isLoggedIn');
}
} catch (error: any) { } catch (error: any) {
set({ set({
connectionStatus: 'error', connectionStatus: 'error',
@@ -185,7 +201,8 @@ export const useAuthStore = create<AuthStoreState>()(
})), })),
partialize: (state) => ({ partialize: (state) => ({
apiBase: state.apiBase, apiBase: state.apiBase,
managementKey: state.managementKey, ...(state.rememberPassword ? { managementKey: state.managementKey } : {}),
rememberPassword: state.rememberPassword,
serverVersion: state.serverVersion, serverVersion: state.serverVersion,
serverBuildDate: state.serverBuildDate serverBuildDate: state.serverBuildDate
}) })

View File

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

View File

@@ -0,0 +1,49 @@
/**
* Quota cache that survives route switches.
*/
import { create } from 'zustand';
import type { AntigravityQuotaState, CodexQuotaState, GeminiCliQuotaState } from '@/types';
type QuotaUpdater<T> = T | ((prev: T) => T);
interface QuotaStoreState {
antigravityQuota: Record<string, AntigravityQuotaState>;
codexQuota: Record<string, CodexQuotaState>;
geminiCliQuota: Record<string, GeminiCliQuotaState>;
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
clearQuotaCache: () => void;
}
const resolveUpdater = <T,>(updater: QuotaUpdater<T>, prev: T): T => {
if (typeof updater === 'function') {
return (updater as (value: T) => T)(prev);
}
return updater;
};
export const useQuotaStore = create<QuotaStoreState>((set) => ({
antigravityQuota: {},
codexQuota: {},
geminiCliQuota: {},
setAntigravityQuota: (updater) =>
set((state) => ({
antigravityQuota: resolveUpdater(updater, state.antigravityQuota)
})),
setCodexQuota: (updater) =>
set((state) => ({
codexQuota: resolveUpdater(updater, state.codexQuota)
})),
setGeminiCliQuota: (updater) =>
set((state) => ({
geminiCliQuota: resolveUpdater(updater, state.geminiCliQuota)
})),
clearQuotaCache: () =>
set({
antigravityQuota: {},
codexQuota: {},
geminiCliQuota: {}
})
}));

View File

@@ -431,6 +431,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $spacing-sm; gap: $spacing-sm;
text-align: center;
.title { .title {
font-size: 22px; font-size: 22px;
@@ -443,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 { .connection-box {
background: var(--bg-secondary); background: var(--bg-secondary);
border: 1px dashed var(--border-color); border: 1px dashed var(--border-color);

View File

@@ -7,6 +7,7 @@
export interface LoginCredentials { export interface LoginCredentials {
apiBase: string; apiBase: string;
managementKey: string; managementKey: string;
rememberPassword?: boolean;
} }
// 认证状态 // 认证状态
@@ -14,6 +15,7 @@ export interface AuthState {
isAuthenticated: boolean; isAuthenticated: boolean;
apiBase: string; apiBase: string;
managementKey: string; managementKey: string;
rememberPassword: boolean;
serverVersion: string | null; serverVersion: string | null;
serverBuildDate: string | null; serverBuildDate: string | null;
} }

View File

@@ -12,3 +12,4 @@ export * from './authFile';
export * from './oauth'; export * from './oauth';
export * from './usage'; export * from './usage';
export * from './log'; export * from './log';
export * from './quota';

50
src/types/quota.ts Normal file
View File

@@ -0,0 +1,50 @@
/**
* Quota management types.
*/
export interface AntigravityQuotaGroup {
id: string;
label: string;
models: string[];
remainingFraction: number;
resetTime?: string;
}
export interface AntigravityQuotaState {
status: 'idle' | 'loading' | 'success' | 'error';
groups: AntigravityQuotaGroup[];
error?: string;
errorStatus?: number;
}
export interface GeminiCliQuotaBucketState {
id: string;
label: string;
remainingFraction: number | null;
remainingAmount: number | null;
resetTime: string | undefined;
tokenType: string | null;
modelIds?: string[];
}
export interface GeminiCliQuotaState {
status: 'idle' | 'loading' | 'success' | 'error';
buckets: GeminiCliQuotaBucketState[];
error?: string;
errorStatus?: number;
}
export interface CodexQuotaWindow {
id: string;
label: string;
usedPercent: number | null;
resetLabel: string;
}
export interface CodexQuotaState {
status: 'idle' | 'loading' | 'success' | 'error';
windows: CodexQuotaWindow[];
planType?: string | null;
error?: string;
errorStatus?: number;
}

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();