From 30f14500c8db3e4d0500785ab1b0a50ecdb4761d Mon Sep 17 00:00:00 2001 From: lmanchu Date: Sun, 12 Apr 2026 14:15:46 +0800 Subject: [PATCH 1/8] feat(i18n): add Traditional Chinese (Taiwan) locale Add zh-TW as a new supported language, covering all 1500+ translation keys with Taiwan-standard terminology and phrasing. Changes: - src/i18n/locales/zh-TW.json: new file, full Traditional Chinese translation - src/i18n/index.ts: import and register zh-TW locale - src/types/common.ts: add 'zh-TW' to Language union type - src/utils/constants.ts: add zh-TW to LANGUAGE_ORDER and LANGUAGE_LABEL_KEYS - src/utils/language.ts: add zh-tw/zh-hant browser locale detection - src/i18n/locales/*.json: add chinese_tw label key to all locales Co-authored-by: lmanchu --- src/i18n/index.ts | 2 + src/i18n/locales/en.json | 1 + src/i18n/locales/ru.json | 1 + src/i18n/locales/zh-CN.json | 1 + src/i18n/locales/zh-TW.json | 1513 +++++++++++++++++++++++++++++++++++ src/types/common.ts | 2 +- src/utils/constants.ts | 3 +- src/utils/language.ts | 1 + 8 files changed, 1522 insertions(+), 2 deletions(-) create mode 100644 src/i18n/locales/zh-TW.json diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 930e323..a026044 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -5,6 +5,7 @@ import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; import zhCN from './locales/zh-CN.json'; +import zhTW from './locales/zh-TW.json'; import en from './locales/en.json'; import ru from './locales/ru.json'; import { getInitialLanguage } from '@/utils/language'; @@ -12,6 +13,7 @@ import { getInitialLanguage } from '@/utils/language'; i18n.use(initReactI18next).init({ resources: { 'zh-CN': { translation: zhCN }, + 'zh-TW': { translation: zhTW }, en: { translation: en }, ru: { translation: ru } }, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 18973d7..76a69a2 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1487,6 +1487,7 @@ "language": { "switch": "Language", "chinese": "中文", + "chinese_tw": "Traditional Chinese (Taiwan)", "english": "English", "russian": "Русский" }, diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 452e8bb..4108834 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1486,6 +1486,7 @@ "language": { "switch": "Язык", "chinese": "中文", + "chinese_tw": "繁體中文(台灣)", "english": "English", "russian": "Русский" }, diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index f0f2d0d..6b9812e 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1487,6 +1487,7 @@ "language": { "switch": "语言", "chinese": "中文", + "chinese_tw": "繁體中文(台灣)", "english": "English", "russian": "Русский" }, diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json new file mode 100644 index 0000000..7db6685 --- /dev/null +++ b/src/i18n/locales/zh-TW.json @@ -0,0 +1,1513 @@ +{ + "common": { + "login": "登入", + "logout": "登出", + "back": "返回", + "cancel": "取消", + "confirm": "確認", + "leave": "離開", + "stay": "繼續編輯", + "save": "儲存", + "delete": "刪除", + "edit": "編輯", + "add": "新增", + "update": "更新", + "refresh": "重新整理", + "close": "關閉", + "success": "成功", + "error": "錯誤", + "info": "資訊", + "warning": "警告", + "loading": "載入中...", + "connecting": "連線中...", + "connected": "已連線", + "disconnected": "未連線", + "connecting_status": "連線中", + "connected_status": "已連線", + "disconnected_status": "未連線", + "yes": "是", + "no": "否", + "not_set": "未設定", + "optional": "選填", + "required": "必填", + "api_key": "金鑰", + "base_url": "位址", + "prefix": "前綴", + "proxy_url": "代理", + "priority": "優先順序", + "alias": "別名", + "failure": "失敗", + "unknown_error": "未知錯誤", + "quota_update_required": "請更新 CPA 版本或檢查更新", + "quota_check_credential": "請檢查憑證狀態", + "copy": "複製", + "expand": "展開", + "collapse": "收起", + "status": "狀態", + "action": "操作", + "custom_headers_label": "自訂請求標頭", + "custom_headers_hint": "選填,設定需要附帶到請求中的 HTTP 標頭,名稱和值均不能為空。", + "custom_headers_add": "新增請求標頭", + "custom_headers_key_placeholder": "Header 名稱,例如 X-Custom-Header", + "custom_headers_value_placeholder": "Header 值", + "model_name_placeholder": "模型名稱,例如 claude-3-5-sonnet-20241022", + "model_alias_placeholder": "模型別名(選填)", + "invalid_provider_index": "無效的供應商索引。", + "unsaved_changes_title": "未儲存的變更", + "unsaved_changes_message": "你有未儲存的變更,離開後將遺失這些變更。確定要離開嗎?" + }, + "title": { + "main": "CLI Proxy API Management Center", + "login": "CLI Proxy API Management Center", + "abbr": "CPAMC" + }, + "splash": { + "title": "CLI Proxy API", + "subtitle": "管理中心" + }, + "auto_login": { + "title": "正在自動登入...", + "message": "正在使用本地儲存的連線資訊嘗試連線伺服器" + }, + "login": { + "subtitle": "請輸入連線資訊以存取管理介面", + "connection_title": "連線位址", + "connection_current": "目前位址", + "connection_auto_hint": "系統將自動使用目前存取位址進行連線", + "custom_connection_label": "自訂連線位址:", + "custom_connection_placeholder": "例如: https://example.com:8317", + "custom_connection_hint": "預設使用目前存取位址,若需要可手動輸入其他位址。", + "use_current_address": "使用目前位址", + "remember_password_label": "記住密碼", + "management_key_label": "管理金鑰:", + "management_key_placeholder": "請輸入管理金鑰", + "connect_button": "連線", + "submit_button": "登入", + "submitting": "連線中...", + "error_title": "登入失敗", + "error_required": "請填寫完整的連線資訊", + "error_invalid": "連線失敗,請檢查位址和金鑰", + "error_network": "網路連線失敗,請檢查網路或伺服器位址", + "error_timeout": "連線逾時,伺服器無回應", + "error_unauthorized": "驗證失敗,管理金鑰無效", + "error_forbidden": "存取被拒絕,權限不足", + "error_not_found": "伺服器位址無效或管理介面未啟用", + "error_server": "伺服器內部錯誤,請稍後再試", + "error_cors": "跨域請求被阻擋,請檢查伺服器設定", + "error_ssl": "SSL/TLS 憑證驗證失敗" + }, + "header": { + "check_connection": "檢查連線", + "refresh_all": "全部重新整理", + "logout": "登出" + }, + "connection": { + "title": "連線資訊", + "server_address": "伺服器位址:", + "management_key": "管理金鑰:", + "status": "連線狀態:" + }, + "nav": { + "dashboard": "儀表板", + "basic_settings": "基本設定", + "api_keys": "API 金鑰", + "ai_providers": "AI 供應商", + "auth_files": "驗證檔案", + "oauth": "OAuth 登入", + "quota_management": "配額管理", + "usage_stats": "使用統計", + "config_management": "設定面板", + "logs": "記錄檢視", + "system_info": "中心資訊" + }, + "dashboard": { + "title": "儀表板", + "subtitle": "歡迎使用 CLI Proxy API 管理中心", + "openai_providers": "OpenAI 供應商", + "quick_actions": "快速操作", + "current_config": "目前設定", + "management_keys": "管理金鑰", + "provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} O:{{openai}}", + "oauth_credentials": "OAuth 憑證", + "usage_overview": "使用概覽", + "total_requests": "總請求數", + "total_tokens": "總 Token 數", + "rpm_30min": "RPM(30 分鐘)", + "tpm_30min": "TPM(30 分鐘)", + "models_used": "使用模型數", + "no_usage_data": "暫無使用資料", + "view_detailed_usage": "查看詳細統計", + "edit_settings": "編輯設定", + "routing_strategy": "路由策略", + "available_models": "可用模型", + "available_models_desc": "所有供應商的模型總數", + "welcome_back": "歡迎回來", + "greeting_morning": "早安", + "greeting_afternoon": "午安", + "greeting_evening": "晚安", + "greeting_night": "夜深了", + "caring_morning": "新的一天,準備大展身手吧。", + "caring_afternoon": "穩步推進中,繼續加油。", + "caring_evening": "今天辛苦了,收尾工作做好喔。", + "caring_night": "夜深了,別忘了早點休息。", + "system_overview": "系統概覽" + }, + "basic_settings": { + "title": "基本設定", + "debug_title": "除錯模式", + "debug_enable": "啟用除錯模式", + "proxy_title": "代理設定", + "proxy_url_label": "代理 URL:", + "proxy_url_placeholder": "例如: socks5://user:pass@127.0.0.1:1080/", + "proxy_update": "更新", + "proxy_clear": "清空", + "retry_title": "請求重試", + "retry_count_label": "重試次數:", + "retry_update": "更新", + "quota_title": "配額超出行為", + "quota_switch_project": "自動切換專案", + "quota_switch_preview": "切換到預覽模型", + "usage_statistics_title": "使用統計", + "usage_statistics_enable": "啟用使用統計", + "logging_title": "記錄", + "logging_to_file_enable": "啟用記錄到檔案", + "logs_max_total_size_title": "記錄容量限制", + "logs_max_total_size_label": "記錄總大小上限(MB):", + "logs_max_total_size_hint": "設定為 0 表示不限制。", + "logs_max_total_size_update": "更新", + "request_log_title": "請求記錄", + "request_log_enable": "啟用請求記錄", + "request_log_warning": "僅在需要排查問題時開啟,日常請保持關閉。", + "force_model_prefix_enable": "強制模型前綴", + "ws_auth_title": "WebSocket 驗證", + "ws_auth_enable": "啟用 /ws/* 驗證", + "routing_title": "路由策略", + "routing_strategy_label": "路由策略:", + "routing_strategy_hint": "round-robin 為輪詢,fill-first 為優先填充。", + "routing_strategy_update": "更新", + "routing_strategy_round_robin": "round-robin(輪詢)", + "routing_strategy_fill_first": "fill-first(優先填充)" + }, + "api_keys": { + "title": "API 金鑰管理", + "proxy_auth_title": "代理服務驗證金鑰", + "add_button": "新增金鑰", + "empty_title": "暫無 API 金鑰", + "empty_desc": "點擊上方按鈕新增第一個金鑰", + "item_title": "API 金鑰", + "add_modal_title": "新增 API 金鑰", + "add_modal_key_label": "API 金鑰:", + "add_modal_key_placeholder": "請輸入 API 金鑰", + "edit_modal_title": "編輯 API 金鑰", + "edit_modal_key_label": "API 金鑰:", + "delete_confirm": "確定要刪除這個 API 金鑰嗎?" + }, + "ai_providers": { + "title": "AI 供應商設定", + "gemini_title": "Gemini API 金鑰", + "gemini_add_button": "新增金鑰", + "gemini_empty_title": "暫無 Gemini 金鑰", + "gemini_empty_desc": "點擊上方按鈕新增第一個金鑰", + "gemini_item_title": "Gemini 金鑰", + "gemini_add_modal_title": "新增 Gemini API 金鑰", + "gemini_add_modal_key_label": "API 金鑰", + "gemini_add_modal_key_placeholder": "輸入 Gemini API 金鑰", + "gemini_add_modal_key_hint": "逐條輸入金鑰,可同時指定選填 Base URL。", + "gemini_keys_add_btn": "新增金鑰", + "gemini_base_url_label": "Base URL(選填)", + "gemini_base_url_placeholder": "例如: https://generativelanguage.googleapis.com", + "gemini_add_modal_proxy_label": "代理 URL(選填):", + "gemini_add_modal_proxy_placeholder": "例如: socks5://proxy.example.com:1080", + "gemini_models_label": "自訂模型(選填):", + "gemini_models_hint": "為空表示使用全部模型;可填寫 name[, alias] 以限制或重新命名模型。", + "gemini_models_add_btn": "新增模型", + "gemini_models_fetch_button": "從 /v1beta/models 取得", + "gemini_models_fetch_title": "從 Gemini /v1beta/models 選擇模型", + "gemini_models_fetch_hint": "按 Gemini API 規範請求 GET /v1beta/models;預設附帶 x-goog-api-key(來自 API Key 欄位)並合併你設定的自訂請求標頭。", + "gemini_models_fetch_url_label": "請求位址", + "gemini_models_fetch_refresh": "重新取得", + "gemini_models_fetch_loading": "正在從 Gemini /v1beta/models 取得模型清單...", + "gemini_models_fetch_empty": "未取得到模型,請檢查 Base URL、API Key 或請求標頭。", + "gemini_models_fetch_error": "取得 Gemini 模型失敗", + "gemini_models_fetch_apply": "新增已選模型", + "model_discovery_select_visible": "全選目前清單", + "model_discovery_clear_selection": "清空已選", + "model_discovery_selected_count": "已選 {{count}} 項", + "gemini_models_search_label": "搜尋模型", + "gemini_models_search_placeholder": "依名稱、別名或描述篩選", + "gemini_models_search_empty": "沒有符合的模型,請更換關鍵字試試。", + "gemini_models_fetch_added": "已新增 {{count}} 個新模型", + "gemini_models_count": "模型數量", + "gemini_edit_modal_title": "編輯 Gemini API 金鑰", + "gemini_edit_modal_key_label": "API 金鑰:", + "gemini_delete_confirm": "確定要刪除這個 Gemini 金鑰嗎?", + "excluded_models_label": "排除的模型(選填):", + "excluded_models_placeholder": "用逗號或換行分隔,例如: gemini-1.5-pro, gemini-1.5-flash", + "excluded_models_hint": "留空表示不篩選;儲存時會自動去重並忽略空白。", + "excluded_models_count": "排除 {{count}} 個模型", + "prefix_label": "前綴(選填):", + "prefix_placeholder": "例如: team-a", + "prefix_hint": "設定後可用 prefix/ 選擇該條目。", + "priority_label": "優先順序(選填):", + "priority_hint": "數值越大優先順序越高;留空表示預設(0)。", + "config_toggle_label": "啟用", + "config_disabled_badge": "已停用", + "codex_title": "Codex API 設定", + "codex_add_button": "新增設定", + "codex_empty_title": "暫無 Codex 設定", + "codex_empty_desc": "點擊上方按鈕新增第一個設定", + "codex_item_title": "Codex 設定", + "codex_add_modal_title": "新增 Codex API 設定", + "codex_add_modal_key_label": "API 金鑰:", + "codex_add_modal_key_placeholder": "請輸入 Codex API 金鑰", + "codex_add_modal_url_label": "Base URL(必填):", + "codex_add_modal_url_placeholder": "例如: https://api.example.com", + "codex_add_modal_proxy_label": "代理 URL(選填):", + "codex_add_modal_proxy_placeholder": "例如: socks5://proxy.example.com:1080", + "codex_websockets_label": "Websockets", + "codex_websockets_hint": "開啟 Responses API 的 websocket 傳輸。", + "codex_models_label": "自訂模型(選填):", + "codex_models_hint": "為空表示使用全部模型;可填寫 name[, alias] 以限制或重新命名模型。", + "codex_models_add_btn": "新增模型", + "codex_models_fetch_button": "從 /v1/models 取得", + "codex_models_fetch_title": "從 /v1/models 選擇模型", + "codex_models_fetch_hint": "使用上方 Base URL 呼叫 /v1/models 端點,附帶 API Key(Bearer)與自訂請求標頭。", + "codex_models_fetch_url_label": "請求位址", + "codex_models_fetch_refresh": "重新取得", + "codex_models_fetch_loading": "正在從 /v1/models 取得模型清單...", + "codex_models_fetch_empty": "未取得到模型,請檢查端點或驗證資訊。", + "codex_models_fetch_error": "取得模型失敗", + "codex_models_fetch_apply": "新增已選模型", + "codex_models_search_label": "搜尋模型", + "codex_models_search_placeholder": "依名稱、別名或描述篩選", + "codex_models_search_empty": "沒有符合的模型,請更換關鍵字試試。", + "codex_models_fetch_added": "已新增 {{count}} 個新模型", + "codex_models_count": "模型數量", + "codex_edit_modal_title": "編輯 Codex API 設定", + "codex_edit_modal_key_label": "API 金鑰:", + "codex_edit_modal_url_label": "Base URL(必填):", + "codex_edit_modal_proxy_label": "代理 URL(選填):", + "codex_delete_confirm": "確定要刪除這個 Codex 設定嗎?", + "claude_title": "Claude API 設定", + "claude_add_button": "新增設定", + "claude_empty_title": "暫無 Claude 設定", + "claude_empty_desc": "點擊上方按鈕新增第一個設定", + "claude_item_title": "Claude 設定", + "claude_add_modal_title": "新增 Claude API 設定", + "claude_add_modal_key_label": "API 金鑰:", + "claude_add_modal_key_placeholder": "請輸入 Claude API 金鑰", + "claude_add_modal_url_label": "Base URL(選填):", + "claude_add_modal_url_placeholder": "例如: https://api.anthropic.com", + "claude_add_modal_proxy_label": "代理 URL(選填):", + "claude_add_modal_proxy_placeholder": "例如: socks5://proxy.example.com:1080", + "claude_edit_modal_title": "編輯 Claude API 設定", + "claude_edit_modal_key_label": "API 金鑰:", + "claude_edit_modal_url_label": "Base URL(選填):", + "claude_edit_modal_proxy_label": "代理 URL(選填):", + "claude_delete_confirm": "確定要刪除這個 Claude 設定嗎?", + "claude_models_label": "自訂模型(選填):", + "claude_models_hint": "為空表示使用全部模型;可填寫 name[, alias] 以限制或重新命名模型。", + "claude_models_add_btn": "新增模型", + "claude_models_count": "模型數量", + "claude_models_fetch_button": "從 /v1/models 取得", + "claude_models_fetch_title": "從 Claude /v1/models 選擇模型", + "claude_models_fetch_hint": "按 Anthropic 規範請求 GET /v1/models,預設附帶 x-api-key 與 anthropic-version: 2023-06-01;也會合併你設定的自訂請求標頭。", + "claude_models_fetch_url_label": "請求位址", + "claude_models_fetch_refresh": "重新取得", + "claude_models_fetch_loading": "正在從 Claude /v1/models 取得模型清單...", + "claude_models_fetch_empty": "未取得到模型,請檢查 Base URL、API Key 或請求標頭。", + "claude_models_fetch_error": "取得 Claude 模型失敗", + "claude_models_fetch_apply": "新增已選模型", + "claude_models_search_label": "搜尋模型", + "claude_models_search_placeholder": "依名稱、別名或描述篩選", + "claude_models_search_empty": "沒有符合的模型,請更換關鍵字試試。", + "claude_models_fetch_added": "已新增 {{count}} 個新模型", + "claude_test_title": "連通性測試", + "claude_test_hint": "按 Anthropic 規範向 /v1/messages 發送測試請求,驗證目前設定是否可用。", + "claude_test_select_placeholder": "從目前模型清單選擇", + "claude_test_select_empty": "目前未設定模型,請先新增模型", + "claude_test_action": "測試", + "claude_test_running": "正在發送 Claude 測試請求...", + "claude_test_timeout": "測試請求逾時({{seconds}} 秒)。", + "claude_test_success": "測試成功,Claude 模型可用。", + "claude_test_failed": "測試失敗", + "claude_test_key_required": "請先填寫 Claude API Key 或在自訂請求標頭中設定 x-api-key", + "claude_test_model_required": "請選擇要測試的模型", + "claude_test_endpoint_invalid": "無法建構有效的 Claude /v1/messages 請求位址", + "claude_cloak_title": "請求偽裝(選填):", + "claude_cloak_toggle_label": "啟用", + "claude_cloak_toggle_aria": "切換請求偽裝", + "claude_cloak_hint": "對非 Claude Code 客戶端,將請求偽裝成 Claude Code CLI 的格式。", + "claude_cloak_mode_label": "模式:", + "claude_cloak_mode_hint": "auto: 僅對非 Claude Code 客戶端偽裝;always: 總是偽裝;never: 從不偽裝。", + "claude_cloak_mode_auto": "自動(僅非 Claude Code)", + "claude_cloak_mode_always": "總是", + "claude_cloak_mode_never": "從不", + "claude_cloak_strict_label": "嚴格模式:", + "claude_cloak_strict_hint": "啟用後會移除用戶 system 訊息,僅保留 Claude Code 提示詞。", + "claude_cloak_sensitive_words_label": "敏感詞(選填):", + "claude_cloak_sensitive_words_placeholder": "用逗號或換行分隔,例如: internal-project", + "claude_cloak_sensitive_words_hint": "這些詞會被插入零寬字元進行混淆。", + "claude_cloak_sensitive_words_count": "敏感詞", + "vertex_title": "Vertex API 設定", + "vertex_add_button": "新增設定", + "vertex_empty_title": "暫無 Vertex 設定", + "vertex_empty_desc": "點擊上方按鈕新增第一個設定", + "vertex_item_title": "Vertex 設定", + "vertex_add_modal_title": "新增 Vertex API 設定", + "vertex_add_modal_key_label": "API 金鑰:", + "vertex_add_modal_key_placeholder": "請輸入 Vertex API 金鑰", + "vertex_add_modal_url_label": "Base URL:", + "vertex_add_modal_url_placeholder": "例如: https://example.com/api", + "vertex_add_modal_proxy_label": "代理 URL(選填):", + "vertex_add_modal_proxy_placeholder": "例如: socks5://proxy.example.com:1080", + "vertex_edit_modal_title": "編輯 Vertex API 設定", + "vertex_edit_modal_key_label": "API 金鑰:", + "vertex_edit_modal_url_label": "Base URL:", + "vertex_edit_modal_proxy_label": "代理 URL(選填):", + "vertex_delete_confirm": "確定要刪除這個 Vertex 設定嗎?", + "vertex_models_label": "模型別名:", + "vertex_models_add_btn": "新增映射", + "vertex_models_count": "別名數量", + "ampcode_title": "Amp CLI 整合(ampcode)", + "ampcode_modal_title": "設定 Ampcode", + "ampcode_upstream_url_label": "Upstream URL", + "ampcode_upstream_url_placeholder": "例如: https://ampcode.com", + "ampcode_upstream_url_hint": "選填;留空表示使用預設/自動探索的控制平面位址。", + "ampcode_upstream_api_key_label": "Upstream API Key(Amp 官方)", + "ampcode_upstream_api_key_placeholder": "輸入 sk-amp...(留空不修改)", + "ampcode_upstream_api_key_hint": "選填;留空不會修改目前 Amp 官方金鑰,需清除請點擊下方按鈕。", + "ampcode_upstream_api_key_current": "目前 Amp 官方金鑰: {{key}}", + "ampcode_clear_upstream_api_key": "清除官方金鑰", + "ampcode_clear_upstream_api_key_confirm": "確定要清除 Ampcode 的 upstream API key(Amp 官方)嗎?", + "ampcode_upstream_api_keys_label": "多上游 API Key 路由", + "ampcode_upstream_api_keys_hint": "為指定客戶端 API Key 綁定不同的 Amp 上游 API Key;客戶端 key 可用逗號或換行分隔。", + "ampcode_upstream_api_keys_add_btn": "新增多上游映射", + "ampcode_upstream_api_keys_upstream_placeholder": "上游 API Key(sk-amp-...)", + "ampcode_upstream_api_keys_clients_placeholder": "客戶端 API Keys,用逗號或換行分隔", + "ampcode_upstream_api_keys_item_title": "上游映射 #{{index}}", + "ampcode_upstream_api_keys_count": "多上游映射", + "ampcode_force_model_mappings_label": "強制套用模型映射", + "ampcode_force_model_mappings_hint": "開啟後,模型映射將覆寫本地 API Key 可用性判斷。", + "ampcode_model_mappings_label": "模型映射(from → to)", + "ampcode_model_mappings_hint": "用於改寫 Amp 請求中的模型名稱;留空表示不做映射。", + "ampcode_model_mappings_add_btn": "新增映射", + "ampcode_model_mappings_from_placeholder": "from 模型(原始)", + "ampcode_model_mappings_to_placeholder": "to 模型(目標)", + "ampcode_model_mappings_count": "映射數量", + "ampcode_lists_overwrite_title": "覆寫清單設定", + "ampcode_lists_overwrite_confirm": "目前未成功載入伺服器已有多上游/模型映射設定,繼續儲存可能覆寫或清空這些清單,是否繼續?", + "ampcode_mappings_overwrite_confirm": "目前未成功載入伺服器已有映射,繼續儲存可能覆寫或清空已有映射,是否繼續?", + "openai_title": "OpenAI 相容供應商", + "openai_add_button": "新增供應商", + "openai_empty_title": "暫無 OpenAI 相容供應商", + "openai_empty_desc": "點擊上方按鈕新增第一個供應商", + "openai_add_modal_title": "新增 OpenAI 相容供應商", + "openai_add_modal_name_label": "供應商名稱:", + "openai_add_modal_name_placeholder": "例如: openrouter", + "openai_add_modal_url_label": "Base URL:", + "openai_add_modal_url_placeholder": "例如: https://openrouter.ai/api/v1", + "openai_add_modal_keys_label": "API 金鑰", + "openai_edit_modal_keys_label": "API 金鑰", + "openai_keys_hint": "每個金鑰可搭配一個選填代理位址,更便於管理。", + "openai_keys_add_btn": "新增金鑰", + "openai_key_placeholder": "輸入 sk- 開頭的金鑰", + "openai_proxy_placeholder": "選填代理 URL(如 socks5://...)", + "openai_add_modal_models_label": "模型清單(name[, alias] 每行一個):", + "openai_models_hint": "範例:gpt-4o-mini 或 moonshotai/kimi-k2:free, kimi-k2", + "openai_model_name_placeholder": "模型名稱,如 moonshotai/kimi-k2:free", + "openai_model_alias_placeholder": "模型別名(選填)", + "openai_models_add_btn": "新增模型", + "openai_models_fetch_button": "從 /models 取得", + "openai_models_fetch_title": "從 /models 選擇模型", + "openai_models_fetch_hint": "使用上方 Base URL 呼叫 /models 端點,附帶第一個 API Key(Bearer)與自訂請求標頭。", + "openai_models_fetch_url_label": "請求位址", + "openai_models_fetch_refresh": "重新取得", + "openai_models_fetch_loading": "正在從 /models 取得模型清單...", + "openai_models_fetch_empty": "未取得到模型,請檢查端點或驗證資訊。", + "openai_models_fetch_error": "取得模型失敗", + "openai_models_fetch_back": "返回編輯", + "openai_models_fetch_apply": "新增已選模型", + "openai_models_search_label": "搜尋模型", + "openai_models_search_placeholder": "依名稱、別名或描述篩選", + "openai_models_search_empty": "沒有符合的模型,請更換關鍵字試試。", + "openai_models_fetch_invalid_url": "請先填寫有效的 Base URL", + "openai_models_fetch_added": "已新增 {{count}} 個新模型", + "openai_edit_modal_title": "編輯 OpenAI 相容供應商", + "openai_edit_modal_name_label": "供應商名稱:", + "openai_edit_modal_url_label": "Base URL:", + "openai_edit_modal_models_label": "模型清單(name[, alias] 每行一個):", + "openai_delete_confirm": "確定要刪除這個 OpenAI 供應商嗎?", + "openai_keys_count": "金鑰數量", + "openai_models_count": "模型數量", + "openai_test_title": "連通性測試", + "openai_test_hint": "使用目前設定向 /chat/completions 請求,驗證是否可用。", + "openai_test_model_placeholder": "選擇或輸入要測試的模型", + "openai_test_action": "發送測試", + "openai_test_running": "正在發送測試請求...", + "openai_test_timeout": "測試請求逾時({{seconds}} 秒)。", + "openai_test_success": "測試成功,模型可用。", + "openai_test_failed": "測試失敗", + "openai_test_select_placeholder": "從目前模型清單選擇", + "openai_test_select_empty": "目前未設定模型,請先新增模型", + "openai_test_single_action": "測試", + "openai_test_all_action": "一鍵測試全部金鑰", + "openai_test_all_hint": "測試所有金鑰的連線狀態", + "openai_test_all_success": "所有 {{count}} 個金鑰測試通過", + "openai_test_all_failed": "所有 {{count}} 個金鑰測試失敗", + "openai_test_all_partial": "測試完成:{{success}} 個通過,{{failed}} 個失敗" + }, + "auth_files": { + "title": "驗證檔案管理", + "title_section": "驗證檔案", + "description": "這裡集中管理 CLI Proxy 支援的所有 JSON 驗證檔案(如 Qwen、Gemini、Vertex 等),上傳後即可在執行時啟用對應的 AI 服務。", + "upload_button": "上傳檔案", + "delete_all_button": "刪除全部", + "empty_title": "暫無驗證檔案", + "empty_desc": "點擊上方按鈕上傳第一個檔案", + "search_empty_title": "沒有符合的設定檔", + "search_empty_desc": "請調整篩選條件或清空搜尋關鍵字再試一次。", + "file_size": "大小", + "file_modified": "修改時間", + "health_status_label": "健康狀態", + "health_status_healthy": "健康", + "health_status_warning": "警告", + "health_status_disabled": "已停用", + "health_status_unknown": "未知", + "health_status_no_message": "暫無狀態說明", + "last_refresh_label": "最近重新整理", + "refresh_not_available": "暫無", + "refresh_just_now": "剛剛", + "download_button": "下載", + "delete_button": "刪除", + "delete_confirm": "確定要刪除檔案", + "delete_all_confirm": "確定要刪除所有驗證檔案嗎?此操作無法還原!", + "delete_filtered_confirm": "確定要刪除篩選出的 {{type}} 驗證檔案嗎?此操作無法還原!", + "delete_problem_button": "刪除問題憑證", + "delete_problem_button_with_type": "刪除 {{type}} 問題憑證", + "delete_problem_confirm": "確定要刪除所有有問題的驗證檔案嗎?此操作無法還原!", + "delete_problem_filtered_confirm": "確定要刪除篩選出的有問題的 {{type}} 驗證檔案嗎?此操作無法還原!", + "upload_error_json": "只能上傳 JSON 檔案", + "upload_error_size": "檔案大小不能超過 {{maxSize}}", + "upload_success": "檔案上傳成功", + "download_success": "檔案下載成功", + "delete_success": "檔案刪除成功", + "delete_all_success": "成功刪除", + "delete_filtered_success": "成功刪除 {{count}} 個 {{type}} 驗證檔案", + "delete_filtered_partial": "{{type}} 驗證檔案刪除完成,成功 {{success}} 個,失敗 {{failed}} 個", + "delete_filtered_none": "目前篩選類型({{type}})下沒有可刪除的驗證檔案", + "delete_problem_success": "成功刪除 {{count}} 個有問題的驗證檔案", + "delete_problem_filtered_success": "成功刪除 {{count}} 個有問題的 {{type}} 驗證檔案", + "delete_problem_partial": "有問題驗證檔案刪除完成,成功 {{success}} 個,失敗 {{failed}} 個", + "delete_problem_filtered_partial": "有問題的 {{type}} 驗證檔案刪除完成,成功 {{success}} 個,失敗 {{failed}} 個", + "delete_problem_none": "目前沒有可刪除的有問題驗證檔案", + "delete_problem_filtered_none": "目前篩選類型({{type}})下沒有可刪除的有問題驗證檔案", + "files_count": "個檔案", + "pagination_prev": "上一頁", + "pagination_next": "下一頁", + "pagination_info": "第 {{current}} / {{total}} 頁 · 共 {{count}} 個檔案", + "search_label": "搜尋設定檔", + "search_placeholder": "輸入名稱、類型或供應方關鍵字,支援 * 萬用字元", + "problem_filter_label": "問題篩選", + "problem_filter_only": "僅顯示有問題憑證", + "display_options_label": "顯示選項", + "compact_mode_label": "簡略模式", + "sort_label": "排序", + "sort_default": "預設", + "sort_az": "A-Z 名稱", + "sort_priority": "優先順序", + "priority_display": "優先順序", + "page_size_label": "單頁數量", + "page_size_unit": "個/頁", + "view_mode_paged": "分頁顯示", + "view_mode_all": "顯示全部", + "too_many_files_warning": "您的憑證總數過多,全部載入會導致頁面卡頓,請保持分頁瀏覽。", + "filter_all": "全部", + "filter_qwen": "Qwen", + "filter_gemini": "Gemini", + "filter_gemini-cli": "GeminiCLI", + "filter_kimi": "Kimi", + "filter_aistudio": "AIStudio", + "filter_claude": "Claude", + "filter_codex": "Codex", + "filter_antigravity": "Antigravity", + "filter_iflow": "iFlow", + "filter_vertex": "Vertex", + "filter_empty": "空檔案", + "filter_unknown": "其他", + "type_qwen": "Qwen", + "type_gemini": "Gemini", + "type_gemini-cli": "GeminiCLI", + "type_kimi": "Kimi", + "type_aistudio": "AIStudio", + "type_claude": "Claude", + "type_codex": "Codex", + "type_antigravity": "Antigravity", + "type_iflow": "iFlow", + "type_vertex": "Vertex", + "type_empty": "空檔案", + "type_unknown": "其他", + "type_virtual": "虛擬驗證檔案", + "models_button": "模型", + "models_title": "支援的模型", + "models_loading": "正在載入模型清單...", + "models_empty": "該憑證暫無可用模型", + "models_empty_desc": "該驗證憑證可能尚未被伺服器載入或沒有綁定任何模型", + "models_unsupported": "目前版本不支援此功能", + "models_unsupported_desc": "請更新 CLI Proxy API 到最新版本後重試", + "models_excluded_badge": "已停用", + "models_excluded_hint": "此 OAuth 模型已被停用", + "status_toggle_label": "啟用", + "status_enabled_success": "已啟用「{{name}}」", + "status_disabled_success": "已停用「{{name}}」", + "batch_status_success": "已成功更新 {{count}} 個檔案", + "batch_status_partial": "成功 {{success}} 個,失敗 {{failed}} 個", + "batch_delete_title": "刪除已選檔案", + "batch_delete_confirm": "確定要刪除 {{count}} 個檔案嗎?", + "batch_selected": "已選 {{count}} 項", + "batch_select_all": "全選", + "batch_select_page": "全選目前頁", + "batch_select_filtered": "全選篩選結果", + "batch_invert_page": "反選目前頁", + "batch_deselect": "取消選擇", + "batch_download": "下載已選", + "batch_download_success": "已開始下載 {{count}} 個檔案", + "batch_download_partial": "下載完成:成功 {{success}} 個,失敗 {{failed}} 個", + "batch_enable": "啟用", + "batch_disable": "停用", + "prefix_proxy_button": "驗證檔案詳情 / 編輯", + "auth_field_editor_title": "驗證檔案詳情 / 編輯 - {{name}}", + "prefix_proxy_loading": "正在載入驗證檔案...", + "prefix_proxy_info_label": "驗證檔案資訊(info)", + "prefix_proxy_source_label": "驗證檔案 JSON(預覽)", + "prefix_label": "前綴(prefix)", + "proxy_url_label": "代理 URL(proxy_url)", + "prefix_placeholder": "", + "proxy_url_placeholder": "socks5://username:password@proxy_ip:port/", + "priority_label": "優先順序(priority)", + "priority_placeholder": "例如: 10 或 -1", + "priority_hint": "僅支援整數;無效值會被忽略。數值越大優先順序越高。", + "excluded_models_label": "排除模型(excluded_models)", + "excluded_models_placeholder": "用逗號或換行分隔,例如: model-a, gpt-5-*, *-preview", + "excluded_models_hint": "儲存為陣列;會自動 trim、小寫、去重並排序。", + "disable_cooling_label": "停用冷卻(disable_cooling)", + "disable_cooling_placeholder": "例如: true / false / 1 / 0", + "disable_cooling_hint": "支援布林值、0/非0 數字或字串 true/false/1/0;無法解析時忽略。", + "note_label": "備註(note)", + "note_placeholder": "輸入備註資訊,例如:張三的帳號", + "note_hint": "選填,用於標記憑證用途或歸屬;留空則不寫入。", + "note_display": "備註", + "headers_label": "自訂請求標頭(headers)", + "headers_placeholder": "{\n \"Header-Name\": \"value\"\n}", + "headers_hint": "以 JSON 物件格式輸入自訂 HTTP 請求標頭,例如:{\"X-My-Header\": \"value\"}", + "headers_invalid_json": "自訂請求標頭必須是有效的 JSON。", + "headers_invalid_object": "自訂請求標頭必須是 JSON 物件。", + "headers_invalid_value": "每個自訂請求標頭的值都必須是字串。", + "prefix_proxy_invalid_json": "該驗證檔案不是 JSON 物件,無法編輯欄位。", + "prefix_proxy_saved_success": "已更新驗證檔案「{{name}}」", + "quota_refresh_success": "已重新整理「{{name}}」的配額", + "quota_refresh_failed": "重新整理「{{name}}」的配額失敗:{{message}}" + }, + "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": "取得全部" + }, + "claude_quota": { + "title": "Claude 配額", + "empty_title": "暫無 Claude OAuth 驗證", + "empty_desc": "使用 Claude OAuth 登入後即可查看配額。", + "idle": "點擊此處重新整理配額", + "loading": "正在載入配額...", + "load_failed": "配額取得失敗:{{message}}", + "missing_auth_index": "驗證檔案缺少 auth_index", + "empty_windows": "暫無配額資料", + "refresh_button": "重新整理配額", + "fetch_all": "取得全部", + "five_hour": "5 小時限額", + "seven_day": "7 天限額", + "seven_day_oauth_apps": "7 天 OAuth 應用", + "seven_day_opus": "7 天 Opus", + "seven_day_sonnet": "7 天 Sonnet", + "seven_day_cowork": "7 天 Cowork", + "iguana_necktie": "Iguana Necktie", + "extra_usage_label": "額外用量", + "plan_label": "方案", + "plan_unknown": "未知", + "plan_free": "免費版", + "plan_pro": "專業版", + "plan_max": "Max", + "plan_max5": "Max 5x", + "plan_max20": "Max 20x" + }, + "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_primary_window": "程式碼審查 5 小時限額", + "code_review_secondary_window": "程式碼審查週限額", + "additional_primary_window": "{{name}} 5 小時限額", + "additional_secondary_window": "{{name}} 週限額", + "plan_label": "方案", + "plan_plus": "Plus", + "plan_team": "Team", + "plan_free": "Free", + "plan_pro": "Pro 20x", + "plan_prolite": "Pro 5x" + }, + "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}}", + "tier_label": "層級", + "tier_free": "免費版", + "tier_legacy": "舊版", + "tier_standard": "標準版", + "tier_pro": "Pro", + "tier_ultra": "Ultra", + "credit_label": "Google One AI 點數", + "credit_amount": "{{count}} 點數" + }, + "kimi_quota": { + "title": "Kimi 配額", + "empty_title": "暫無 Kimi 驗證", + "empty_desc": "上傳 Kimi 驗證檔案後即可查看配額。", + "idle": "點擊此處重新整理配額", + "loading": "正在載入配額...", + "load_failed": "配額取得失敗:{{message}}", + "missing_auth_index": "驗證檔案缺少 auth_index", + "empty_data": "暫無配額資料", + "refresh_button": "重新整理配額", + "fetch_all": "取得全部", + "weekly_limit": "週限額", + "limit_window": "{{duration}} 限額", + "limit_index": "限額 #{{index}}", + "reset_hint": "{{hint}} 後重置" + }, + "vertex_import": { + "title": "Vertex JSON 登入", + "description": "上傳 Google 服務帳號 JSON,使用 CLI vertex-import 同步規則寫入 auth-dir/vertex-.json。", + "location_label": "目標區域(選填)", + "location_placeholder": "us-central1", + "location_hint": "留空表示使用預設區域 us-central1。", + "file_label": "服務帳號金鑰 JSON", + "file_hint": "僅支援 Google Cloud service account key JSON 檔案,私鑰會自動規範化。", + "file_placeholder": "尚未選擇檔案", + "choose_file": "選擇檔案", + "import_button": "匯入 Vertex 憑證", + "file_required": "請先選擇 .json 憑證檔案", + "success": "Vertex 憑證匯入成功", + "result_title": "憑證已儲存", + "result_project": "專案 ID", + "result_email": "服務帳號", + "result_location": "區域", + "result_file": "儲存檔案" + }, + "oauth_excluded": { + "title": "OAuth 模型停用", + "description": "按供應商分列展示,點擊卡片編輯或刪除;支援 * 萬用字元,範圍跟隨上方的設定檔篩選標籤。", + "add": "新增停用", + "add_title": "新增供應商模型停用", + "edit_title": "編輯 {{provider}} 的模型停用", + "refresh": "重新整理", + "refreshing": "重新整理中...", + "provider_label": "供應商", + "provider_auto": "跟隨目前篩選", + "provider_placeholder": "例如 gemini-cli / openai", + "provider_hint": "預設選取目前篩選的供應商,也可直接輸入或選擇其他名稱。", + "models_label": "停用的模型", + "models_loading": "正在載入模型清單...", + "models_unsupported": "目前 CPA 版本不支援取得模型清單。", + "models_loaded": "已載入 {{count}} 個模型,勾選要停用的模型。", + "no_models_available": "該供應商暫無可用模型清單。", + "save": "儲存/更新", + "saving": "正在儲存...", + "save_success": "模型停用已更新", + "save_failed": "更新模型停用失敗", + "delete": "刪除供應商", + "delete_confirm": "確定要刪除 {{provider}} 的模型停用嗎?", + "delete_success": "已刪除該供應商的模型停用", + "delete_failed": "刪除模型停用失敗", + "deleting": "正在刪除...", + "no_models": "未設定停用模型", + "model_count": "停用 {{count}} 個模型", + "list_empty_all": "暫無任何供應商的模型停用,點擊「新增停用」建立。", + "list_empty_filtered": "目前篩選下沒有停用項,點擊「新增停用」新增。", + "disconnected": "請先連線伺服器以查看模型停用", + "load_failed": "載入模型停用失敗", + "provider_required": "請先填寫供應商名稱", + "scope_all": "目前範圍:全域(顯示所有供應商)", + "scope_provider": "目前範圍:{{provider}}", + "upgrade_required": "目前 CPA 版本不支援 OAuth 模型停用,請升級 CPA 版本", + "upgrade_required_title": "需要升級 CPA 版本", + "upgrade_required_desc": "目前伺服器版本不支援取得 OAuth 模型停用功能,請升級到最新版本的 CPA(CLI Proxy API)後重試。" + }, + "oauth_model_alias": { + "title": "OAuth 模型別名", + "add": "新增別名", + "add_title": "新增供應商模型別名", + "provider_label": "供應商", + "provider_placeholder": "例如 gemini-cli / vertex", + "provider_hint": "預設選取目前篩選的供應商,也可直接輸入或選擇其他名稱。", + "model_source_loading": "正在載入模型清單...", + "model_source_unsupported": "目前 CPA 版本不支援取得模型清單(仍可手動輸入)。", + "model_source_loaded": "已載入 {{count}} 個模型,可在「原模型名稱」中下拉選擇;也可手動輸入。留空儲存將刪除該供應商記錄;開啟「保留原名」會在保留原模型名的同時新增別名。", + "alias_label": "模型別名", + "alias_name_placeholder": "原模型名稱", + "alias_placeholder": "別名(必填)", + "alias_fork_label": "保留原名", + "add_alias": "新增別名", + "save": "儲存/更新", + "save_success": "模型別名已更新", + "save_failed": "更新模型別名失敗", + "delete": "刪除供應商", + "delete_confirm": "確定要刪除 {{provider}} 的模型別名嗎?", + "delete_link_title": "取消連結", + "delete_link_confirm": "確定取消 {{sourceModel}}({{provider}})到別名 {{alias}} 的映射?", + "delete_alias_title": "刪除別名", + "delete_alias_confirm": "確定刪除別名 {{alias}} 並取消所有關聯模型的映射?", + "delete_success": "已刪除該供應商的模型別名", + "delete_failed": "刪除模型別名失敗", + "no_models": "未設定模型別名", + "model_count": "{{count}} 條別名", + "list_empty_all": "暫無任何供應商的模型別名,點擊「新增別名」建立。", + "chart_title": "全部映射概覽", + "diagram_providers": "供應商", + "diagram_source_models": "來源模型", + "diagram_aliases": "別名", + "diagram_expand": "展開", + "diagram_collapse": "收起", + "diagram_add_alias": "新增別名", + "diagram_rename": "重新命名", + "diagram_rename_alias_title": "重新命名別名", + "diagram_rename_alias_label": "新別名名稱", + "diagram_rename_placeholder": "輸入別名名稱...", + "diagram_delete_link": "取消連結 {{provider}} / {{name}}", + "diagram_delete_alias": "刪除別名", + "diagram_please_enter_alias": "請輸入別名名稱。", + "diagram_alias_exists": "該別名已存在。", + "diagram_add_alias_title": "新增別名", + "diagram_add_alias_label": "別名名稱", + "diagram_add_placeholder": "輸入新別名名稱...", + "diagram_rename_btn": "重新命名", + "diagram_add_btn": "新增", + "diagram_settings": "設定", + "diagram_settings_title": "別名設定 — {{alias}}", + "diagram_settings_source_title": "來源模型設定", + "diagram_settings_empty": "該別名暫無映射。", + "diagram_tap_hint": "觸控設備上:先點選來源模型,再點選別名即可建立映射。", + "view_mode": "檢視模式", + "view_mode_diagram": "概覽", + "view_mode_list": "管理", + "provider_required": "請先填寫供應商名稱", + "upgrade_required": "目前 CPA 版本不支援模型別名功能,請升級 CPA 版本", + "upgrade_required_title": "需要升級 CPA 版本", + "upgrade_required_desc": "目前伺服器版本不支援 OAuth 模型別名功能,請升級到最新版本的 CPA(CLI Proxy API)後重試。" + }, + "auth_login": { + "codex_oauth_title": "Codex OAuth", + "codex_oauth_button": "開始 Codex 登入", + "codex_oauth_hint": "透過 OAuth 流程登入 Codex 服務,自動取得並儲存驗證檔案。", + "codex_oauth_url_label": "授權連結:", + "codex_open_link": "開啟連結", + "codex_copy_link": "複製連結", + "codex_oauth_status_waiting": "等待驗證中...", + "codex_oauth_status_success": "驗證成功!", + "codex_oauth_status_error": "驗證失敗:", + "codex_oauth_start_error": "啟動 Codex OAuth 失敗:", + "codex_oauth_polling_error": "檢查驗證狀態失敗:", + "anthropic_oauth_title": "Anthropic OAuth", + "anthropic_oauth_button": "開始 Anthropic 登入", + "anthropic_oauth_hint": "透過 OAuth 流程登入 Anthropic(Claude)服務,自動取得並儲存驗證檔案。", + "anthropic_oauth_url_label": "授權連結:", + "anthropic_open_link": "開啟連結", + "anthropic_copy_link": "複製連結", + "anthropic_oauth_status_waiting": "等待驗證中...", + "anthropic_oauth_status_success": "驗證成功!", + "anthropic_oauth_status_error": "驗證失敗:", + "anthropic_oauth_start_error": "啟動 Anthropic OAuth 失敗:", + "anthropic_oauth_polling_error": "檢查驗證狀態失敗:", + "antigravity_oauth_title": "Antigravity OAuth", + "antigravity_oauth_button": "開始 Antigravity 登入", + "antigravity_oauth_hint": "透過 OAuth 流程登入 Antigravity(Google 帳號)服務,自動取得並儲存驗證檔案。", + "antigravity_oauth_url_label": "授權連結:", + "antigravity_open_link": "開啟連結", + "antigravity_copy_link": "複製連結", + "antigravity_oauth_status_waiting": "等待驗證中...", + "antigravity_oauth_status_success": "驗證成功!", + "antigravity_oauth_status_error": "驗證失敗:", + "antigravity_oauth_start_error": "啟動 Antigravity OAuth 失敗:", + "antigravity_oauth_polling_error": "檢查驗證狀態失敗:", + "gemini_cli_oauth_title": "Gemini CLI OAuth", + "gemini_cli_oauth_button": "開始 Gemini CLI 登入", + "gemini_cli_oauth_hint": "透過 OAuth 流程登入 Google Gemini CLI 服務,自動取得並儲存驗證檔案。", + "gemini_cli_project_id_label": "Google Cloud 專案 ID(選填):", + "gemini_cli_project_id_placeholder": "留空將自動選擇第一個可用專案", + "gemini_cli_project_id_hint": "可選填專案 ID。如不填寫,系統將自動選擇您帳號下的第一個可用專案。輸入 ALL 可取得全部專案。", + "gemini_cli_project_id_required": "請填寫 Google Cloud 專案 ID。", + "gemini_cli_oauth_url_label": "授權連結:", + "gemini_cli_open_link": "開啟連結", + "gemini_cli_copy_link": "複製連結", + "gemini_cli_oauth_status_waiting": "等待驗證中...", + "gemini_cli_oauth_status_success": "驗證成功!", + "gemini_cli_oauth_status_error": "驗證失敗:", + "gemini_cli_oauth_start_error": "啟動 Gemini CLI OAuth 失敗:", + "gemini_cli_oauth_polling_error": "檢查驗證狀態失敗:", + "kimi_oauth_title": "Kimi OAuth", + "kimi_oauth_button": "開始 Kimi 登入", + "kimi_oauth_hint": "透過裝置授權流程登入 Kimi 服務,自動取得並儲存驗證檔案。", + "kimi_oauth_url_label": "授權連結:", + "kimi_open_link": "開啟連結", + "kimi_copy_link": "複製連結", + "kimi_oauth_status_waiting": "等待驗證中...", + "kimi_oauth_status_success": "驗證成功!", + "kimi_oauth_status_error": "驗證失敗:", + "kimi_oauth_start_error": "啟動 Kimi OAuth 失敗:", + "kimi_oauth_polling_error": "檢查驗證狀態失敗:", + "qwen_oauth_title": "Qwen OAuth", + "qwen_oauth_button": "開始 Qwen 登入", + "qwen_oauth_hint": "透過裝置授權流程登入 Qwen 服務,自動取得並儲存驗證檔案。", + "qwen_oauth_url_label": "授權連結:", + "qwen_open_link": "開啟連結", + "qwen_copy_link": "複製連結", + "qwen_oauth_status_waiting": "等待驗證中...", + "qwen_oauth_status_success": "驗證成功!", + "qwen_oauth_status_error": "驗證失敗:", + "qwen_oauth_start_error": "啟動 Qwen OAuth 失敗:", + "qwen_oauth_polling_error": "檢查驗證狀態失敗:", + "oauth_callback_label": "回調 URL", + "oauth_callback_placeholder": "http://localhost:1455/auth/callback?code=...&state=...", + "oauth_callback_hint": "遠端瀏覽器模式:當授權跳轉到 http://localhost:... 後,複製完整 URL 並提交到這裡。", + "oauth_callback_button": "提交回調 URL", + "oauth_callback_required": "請先貼上完整的回調 URL。", + "oauth_callback_success": "回調 URL 已提交,請繼續等待驗證。", + "oauth_callback_error": "提交回調 URL 失敗:", + "oauth_callback_upgrade_hint": "請更新 CLI Proxy API 或檢查連線", + "oauth_callback_status_success": "回調 URL 已提交,等待驗證中...", + "oauth_callback_status_error": "回調 URL 提交失敗:", + "missing_state": "無法取得驗證狀態參數", + "iflow_oauth_title": "iFlow OAuth", + "iflow_oauth_button": "開始 iFlow 登入", + "iflow_oauth_hint": "透過 OAuth 流程登入 iFlow 服務,自動取得並儲存驗證檔案。", + "iflow_oauth_url_label": "授權連結:", + "iflow_open_link": "開啟連結", + "iflow_copy_link": "複製連結", + "iflow_oauth_status_waiting": "等待驗證中...", + "iflow_oauth_status_success": "驗證成功!", + "iflow_oauth_status_error": "驗證失敗:", + "iflow_oauth_start_error": "啟動 iFlow OAuth 失敗:", + "iflow_oauth_polling_error": "檢查驗證狀態失敗:", + "iflow_cookie_title": "iFlow Cookie 登入", + "iflow_cookie_label": "Cookie 內容:", + "iflow_cookie_placeholder": "填入 BXAuth 值,以 BXAuth= 開頭", + "iflow_cookie_hint": "直接提交 Cookie 以完成登入(無需開啟授權連結),伺服器端將自動儲存憑證。", + "iflow_cookie_key_hint": "提示:需在平台上先建立 Key。", + "iflow_cookie_button": "提交 Cookie 登入", + "iflow_cookie_status_success": "Cookie 登入成功,憑證已儲存。", + "iflow_cookie_status_error": "Cookie 登入失敗:", + "iflow_cookie_status_duplicate": "設定檔重複:", + "iflow_cookie_start_error": "提交 Cookie 登入失敗:", + "iflow_cookie_config_duplicate": "偵測到設定檔已存在(重複),如需重新儲存請先刪除原檔案後重試。", + "iflow_cookie_required": "請先填寫 Cookie 內容", + "iflow_cookie_result_title": "Cookie 登入結果", + "iflow_cookie_result_email": "帳號", + "iflow_cookie_result_expired": "到期時間", + "iflow_cookie_result_path": "儲存路徑", + "iflow_cookie_result_type": "類型", + "remote_access_disabled": "遠端存取不支援此登入方式,請從本地(localhost)存取" + }, + "usage_stats": { + "title": "使用統計", + "total_requests": "總請求數", + "success_requests": "成功請求", + "failed_requests": "失敗請求", + "total_tokens": "總 Token 數", + "cached_tokens": "快取 Tokens", + "reasoning_tokens": "推理 Tokens", + "rpm_30m": "RPM", + "tpm_30m": "TPM", + "rate_30m": "近 30 分鐘速率", + "model_name": "模型名稱", + "model_price_settings": "模型定價設定", + "saved_prices": "已儲存的定價", + "requests_trend": "請求趨勢", + "tokens_trend": "Token 使用趨勢", + "api_details": "API 詳細統計", + "by_hour": "依小時", + "by_day": "依天", + "range_filter": "時間範圍", + "range_all": "全部時間", + "range_7h": "最近 7 小時", + "range_24h": "最近 24 小時", + "range_7d": "最近 7 天", + "filter_all": "全部", + "clear_filters": "清空篩選", + "refresh": "重新整理", + "export": "匯出資料", + "import": "匯入資料", + "export_csv": "匯出 CSV", + "export_json": "匯出 JSON", + "export_success": "使用統計已匯出", + "import_success": "匯入完成:新增 {{added}},跳過 {{skipped}},總請求 {{total}},失敗 {{failed}}", + "import_invalid": "匯入檔案格式不正確", + "chart_line_label_1": "曲線 1", + "chart_line_label_2": "曲線 2", + "chart_line_label_3": "曲線 3", + "chart_line_label_4": "曲線 4", + "chart_line_label_5": "曲線 5", + "chart_line_label_6": "曲線 6", + "chart_line_label_7": "曲線 7", + "chart_line_label_8": "曲線 8", + "chart_line_label_9": "曲線 9", + "chart_line_hidden": "不顯示", + "chart_line_actions_label": "曲線數量", + "chart_line_add": "增加曲線", + "chart_line_all": "全部", + "chart_line_delete": "刪除曲線", + "chart_line_hint": "最多同時顯示 9 條模型曲線", + "no_data": "暫無資料", + "loading_error": "載入失敗", + "api_endpoint": "API 端點", + "requests_count": "請求次數", + "tokens_count": "Token 數量", + "models": "模型統計", + "success_rate": "成功率", + "total_cost": "總花費", + "total_cost_hint": "基於已設定的模型單價", + "model_price_title": "模型定價", + "model_price_reset": "清除定價", + "model_price_model_label": "選擇模型", + "model_price_select_placeholder": "選擇模型", + "model_price_select_hint": "模型清單來自使用統計明細", + "model_price_prompt": "提示定價", + "model_price_completion": "補全定價", + "model_price_cache": "快取定價", + "model_price_save": "儲存定價", + "model_price_empty": "尚未設定任何模型定價", + "model_price_model": "模型", + "model_price_saved": "模型定價已儲存", + "model_price_model_required": "請選擇要設定定價的模型", + "cost_trend": "花費統計", + "cost_axis_label": "花費($)", + "cost_need_price": "請先設定模型定價", + "cost_need_usage": "暫無使用資料,無法計算花費", + "cost_no_data": "沒有可計算的花費資料", + "credential_stats": "憑證統計", + "credential_name": "憑證", + "token_breakdown": "Token 類型分布", + "request_events_title": "請求事件明細", + "request_events_filter_model": "模型", + "request_events_filter_source": "來源", + "request_events_filter_auth_index": "驗證索引", + "request_events_timestamp": "時間", + "request_events_source": "來源", + "request_events_auth_index": "驗證索引", + "request_events_result": "結果", + "time": "延遲", + "avg_time": "平均延遲", + "total_time": "總延遲", + "latency_unit_hint": "耗時取自後端欄位 {{field}},按 {{unit}} 解讀後再格式化顯示。", + "duration_unit_d": "天", + "duration_unit_h": "時", + "duration_unit_m": "分", + "duration_unit_s": "秒", + "duration_unit_ms": "毫秒", + "request_events_empty_title": "暫無請求事件", + "request_events_empty_desc": "目前時間範圍內暫無可用的請求明細資料。", + "request_events_no_result_title": "沒有符合結果", + "request_events_no_result_desc": "請嘗試調整篩選條件,或清空目前篩選。", + "request_events_count": "{{count}} 條事件", + "request_events_limit_hint": "為確保渲染效能,僅展示 {{shown}} / {{total}} 條事件。", + "input_tokens": "輸入 Tokens", + "output_tokens": "輸出 Tokens", + "last_updated": "更新於" + }, + "stats": { + "success": "成功", + "failure": "失敗" + }, + "status_bar": { + "success_short": "✓", + "failure_short": "✗", + "no_requests": "無請求" + }, + "service_health": { + "title": "服務健康監測", + "window": "最近 7 天", + "oldest": "最早", + "newest": "最新" + }, + "logs": { + "title": "記錄檢視", + "refresh_button": "重新整理記錄", + "clear_button": "清空記錄", + "download_button": "下載記錄", + "error_log_button": "選擇錯誤記錄", + "error_logs_modal_title": "錯誤請求記錄", + "error_logs_description": "請選擇要下載的錯誤請求記錄檔案(僅在關閉請求記錄時產生)。", + "error_logs_request_log_enabled": "目前已開啟請求記錄,按介面約定錯誤請求記錄清單會始終為空。關閉請求記錄後再重新整理即可查看。", + "error_logs_empty": "暫無錯誤請求記錄檔案", + "error_logs_load_error": "載入錯誤記錄清單失敗", + "error_logs_size": "大小", + "error_logs_modified": "最後修改", + "error_logs_download": "下載", + "error_log_download_success": "錯誤記錄下載成功", + "request_log_download_title": "下載封包", + "request_log_download_confirm": "是否要下載 id 為 {{id}} 的封包?", + "request_log_download_success": "封包下載成功", + "empty_title": "暫無記錄", + "empty_desc": "當啟用「記錄到檔案」功能後,記錄將顯示在這裡", + "log_content": "記錄內容", + "loading": "正在載入記錄...", + "load_error": "載入記錄失敗", + "clear_confirm": "確定要清空所有記錄嗎?此操作無法還原!", + "clear_success": "記錄已清空", + "download_success": "記錄下載成功", + "auto_refresh": "自動重新整理", + "auto_refresh_enabled": "自動重新整理已開啟", + "auto_refresh_disabled": "自動重新整理已關閉", + "load_more_hint": "向上滾動載入更多", + "hidden_lines": "已隱藏 {{count}} 行", + "loaded_lines": "已載入 {{count}} 行", + "filtered_lines": "已篩選 {{count}} 行", + "hide_management_logs": "隱藏 {{prefix}} 記錄", + "show_raw_logs": "顯示原始記錄", + "show_raw_logs_hint": "直接顯示原始記錄文字,方便多行複製", + "search_placeholder": "搜尋記錄內容或關鍵字", + "filter_panel_title": "結構化篩選", + "filter_panel_expand": "展開結構化篩選", + "filter_panel_collapse": "收起結構化篩選", + "filter_panel_active_count": "已選 {{count}} 項", + "filter_method": "請求方法", + "filter_status": "狀態碼", + "filter_path": "路徑", + "filter_path_empty": "暫無可篩選路徑", + "filter_status_2xx": "2xx", + "filter_status_3xx": "3xx", + "filter_status_4xx": "4xx", + "filter_status_5xx": "5xx", + "clear_filters": "清空篩選", + "trace_button": "請求追蹤", + "trace_title": "請求追蹤(V1)", + "trace_notice": "V1 透過記錄與 usage 事件做啟發式關聯,結果為估算,不是嚴格 request_id 級精確比對。", + "trace_log_info": "記錄事件", + "trace_candidates_title": "Usage 候選比對", + "trace_loading": "正在比對 usage 事件...", + "trace_no_match": "未找到候選比對結果。", + "trace_usage_load_error": "載入請求追蹤所需 usage 明細失敗", + "trace_download_request_log": "下載請求記錄", + "trace_confidence_high": "高", + "trace_confidence_medium": "中", + "trace_confidence_low": "低", + "trace_score": "分數 {{score}}", + "trace_delta_seconds": "時間差 {{seconds}} 秒", + "trace_model_matched": "模型比對", + "trace_request_id": "請求 ID", + "trace_method": "請求方法", + "trace_path": "路徑", + "trace_status_code": "狀態碼", + "trace_latency": "耗時", + "trace_ip": "IP", + "trace_timestamp": "時間", + "trace_message": "訊息", + "trace_endpoint": "Usage 介面", + "trace_model": "模型", + "trace_source": "來源", + "trace_auth_index": "驗證索引", + "trace_result": "結果", + "search_empty_title": "未找到符合的記錄", + "search_empty_desc": "嘗試更換關鍵字或清空篩選條件。", + "double_click_copy_hint": "雙擊複製記錄原文", + "copy_success": "已複製記錄原文", + "copy_failed": "複製失敗", + "lines": "行", + "removed": "已篩選", + "upgrade_required_title": "需要升級 CLI Proxy API", + "upgrade_required_desc": "目前伺服器版本不支援記錄檢視功能,請升級到最新版本的 CLI Proxy API 以使用此功能。" + }, + "config_management": { + "title": "設定面板", + "editor_title": "設定檔", + "reload": "重新載入", + "reload_confirm_message": "重新載入將捨棄你目前未儲存的修改,確定繼續嗎?", + "save": "儲存", + "description": "透過視覺化或原始檔方式編輯 config.yaml 設定檔", + "status_idle": "等待操作", + "status_loading": "載入設定中...", + "status_loading_short": "載入中", + "status_loaded": "設定已載入", + "status_loaded_short": "已載入", + "status_dirty": "有未儲存的變更", + "status_dirty_short": "未儲存", + "status_disconnected": "請先連線伺服器以載入設定", + "status_disconnected_short": "未連線", + "status_load_failed": "載入失敗", + "status_load_failed_short": "失敗", + "status_saving": "正在儲存設定...", + "status_saving_short": "儲存中", + "status_saved": "設定儲存完成", + "status_save_failed": "儲存失敗", + "save_success": "設定已儲存", + "error_yaml_not_supported": "伺服器未回傳 YAML 格式,請確認 /config.yaml 介面可用", + "visual_mode_unavailable": "YAML 語法修復前無法使用視覺化編輯", + "visual_mode_unavailable_short": "YAML 錯誤", + "validation_blocked_short": "待修復", + "visual_mode_unavailable_detail": "目前設定存在無效 YAML,暫時無法使用視覺化編輯:{{message}}", + "visual_mode_save_blocked": "請先修復 YAML 語法錯誤,再從視覺化模式儲存", + "visual_mode_latest_yaml_invalid": "伺服器最新設定包含無效 YAML,請先切回原始碼模式檢查後再儲存視覺化修改:{{message}}", + "editor_placeholder": "key: value", + "search_placeholder": "搜尋設定內容...", + "search_button": "搜尋", + "search_no_results": "無結果", + "search_prev": "上一個", + "search_next": "下一個", + "diff": { + "title": "確認變更", + "current": "目前設定", + "modified": "修改後", + "confirm": "確認儲存", + "no_changes": "未偵測到變更" + }, + "tabs": { + "visual": "視覺化編輯", + "source": "原始檔編輯" + }, + "visual": { + "notice": "視覺化模式涵蓋常用欄位,未涵蓋的設定仍需在原始檔模式中查看或編輯。", + "quick_jump": "快速跳轉", + "sections": { + "server": { + "title": "伺服器設定", + "description": "基本伺服器設定", + "host": "主機位址", + "port": "連接埠" + }, + "tls": { + "title": "TLS/SSL 設定", + "description": "HTTPS 安全連線設定", + "enable": "啟用 TLS", + "enable_desc": "啟用 HTTPS 安全連線", + "cert": "憑證檔案路徑", + "key": "私鑰檔案路徑" + }, + "remote": { + "title": "遠端管理", + "description": "遠端存取和控制面板設定", + "allow_remote": "允許遠端存取", + "allow_remote_desc": "允許從其他主機存取管理介面", + "disable_panel": "停用控制面板", + "disable_panel_desc": "停用內建的 Web 控制面板", + "secret_key": "管理金鑰", + "secret_key_placeholder": "設定管理金鑰", + "panel_repo": "面板儲存庫" + }, + "auth": { + "title": "驗證設定", + "description": "API 金鑰與驗證檔案目錄設定", + "auth_dir": "驗證檔案目錄(auth-dir)", + "auth_dir_hint": "存放驗證檔案的目錄路徑(支援 ~)" + }, + "system": { + "title": "系統設定", + "description": "除錯、記錄、統計與效能調整設定", + "debug": "除錯模式", + "debug_desc": "啟用詳細的除錯記錄", + "commercial_mode": "商業模式", + "commercial_mode_desc": "停用高開銷中介軟體以支援高並行", + "logging_to_file": "寫入記錄檔", + "logging_to_file_desc": "將記錄儲存到檔案", + "usage_statistics": "使用統計", + "usage_statistics_desc": "收集使用統計資訊", + "logs_max_size": "記錄檔大小限制(MB)" + }, + "network": { + "title": "網路設定", + "description": "代理、重試和路由設定", + "proxy_url": "代理 URL", + "request_retry": "請求重試次數", + "max_retry_credentials": "最大重試憑證數", + "max_retry_credentials_hint": "留空表示不設定;設為 0 表示保留 legacy 行為,並嘗試所有可用憑證。", + "max_retry_interval": "最大重試間隔(秒)", + "routing_strategy": "路由策略", + "routing_strategy_hint": "選擇憑證選擇策略", + "strategy_round_robin": "輪詢(Round Robin)", + "strategy_fill_first": "填充優先(Fill First)", + "force_model_prefix": "強制模型前綴", + "force_model_prefix_desc": "未帶前綴的模型請求只使用無前綴憑證", + "ws_auth": "WebSocket 驗證", + "ws_auth_desc": "啟用 WebSocket 連線驗證(/v1/ws)" + }, + "quota": { + "title": "配額回退", + "description": "配額耗盡時的回退策略", + "switch_project": "切換專案", + "switch_project_desc": "配額耗盡時自動切換到其他專案", + "switch_preview_model": "切換預覽模型", + "switch_preview_model_desc": "配額耗盡時切換到預覽版本模型", + "antigravity_credits": "Antigravity Credits 重試", + "antigravity_credits_desc": "Antigravity 回傳 quota_exhausted 429 時,使用 enabledCreditTypes=[\"GOOGLE_ONE_AI\"] 重試一次" + }, + "streaming": { + "title": "串流傳輸設定", + "description": "Keepalive 與 bootstrap 重試設定", + "keepalive_seconds": "Keepalive 秒數", + "keepalive_hint": "設定為 0 或留空表示停用 keepalive", + "bootstrap_retries": "Bootstrap 重試次數", + "bootstrap_hint": "串流傳輸啟動時(首包前)的重試次數", + "nonstream_keepalive": "非串流 Keepalive 間隔(秒)", + "nonstream_keepalive_hint": "非串流回應時每隔 N 秒發送空行以防止閒置逾時,設定為 0 或留空表示停用", + "disabled": "已停用" + }, + "payload": { + "title": "Payload 設定", + "description": "預設值、原始 JSON 規則、覆寫規則與篩選規則", + "default_rules": "預設規則", + "default_rules_desc": "當請求中未指定參數時,使用這些預設值", + "default_raw_rules": "預設 Raw 規則", + "default_raw_rules_desc": "當請求中未指定參數時,按原始 JSON 片段寫入這些值,例如 true、123、\"high\" 或 {\"type\":\"object\"}", + "override_rules": "覆寫規則", + "override_rules_desc": "強制覆寫請求中的參數值", + "override_raw_rules": "覆寫 Raw 規則", + "override_raw_rules_desc": "始終按原始 JSON 片段覆寫參數值,適合 response_format、schema 等複雜欄位", + "filter_rules": "篩選規則", + "filter_rules_desc": "透過 JSON Path 預篩選上游請求體,自動剔除不合規/冗餘參數(Request Sanitization)" + } + }, + "api_keys": { + "label": "API 金鑰清單(api-keys)", + "add": "新增 API 金鑰", + "generate": "產生", + "empty": "暫無 API 金鑰", + "hint": "每個條目代表一個 API 金鑰(與「API 金鑰管理」頁面樣式一致)", + "edit_title": "編輯 API 金鑰", + "add_title": "新增 API 金鑰", + "input_label": "API 金鑰", + "input_placeholder": "貼上你的 API 金鑰", + "input_hint": "此處僅修改本地設定檔內容,不會自動同步到 API 金鑰管理介面", + "error_empty": "請輸入 API 金鑰", + "error_invalid": "API 金鑰包含無效字元" + }, + "payload_rules": { + "rule": "規則", + "models": "適用模型", + "model_name": "模型名稱", + "provider_type": "供應商類型", + "add_model": "新增模型", + "params": "參數設定", + "remove_params": "移除參數", + "json_path": "JSON 路徑(如 temperature)", + "json_path_filter": "JSON 路徑(gjson/sjson),如 generationConfig.thinkingConfig.thinkingBudget", + "param_type": "參數類型", + "param_value": "參數值", + "add_param": "新增參數", + "no_rules": "暫無規則", + "add_rule": "新增規則", + "provider_default": "預設", + "provider_openai": "OpenAI", + "provider_openai_response": "OpenAI Response", + "provider_gemini": "Gemini", + "provider_claude": "Claude", + "provider_codex": "Codex", + "provider_antigravity": "Antigravity", + "value_type_string": "字串", + "value_type_number": "數字", + "value_type_boolean": "布林", + "value_type_json": "JSON", + "value_string": "字串值", + "value_number": "數字值(如 0.7)", + "value_boolean": "true 或 false", + "value_json": "JSON 值", + "value_raw_json": "原始 JSON 片段,如 true、123、\"high\" 或 {\"type\":\"object\"}", + "value_default": "值", + "boolean_true": "true", + "boolean_false": "false" + }, + "validation": { + "validation_blocked": "請先修復表單驗證錯誤再儲存", + "port_range": "請輸入 1 到 65535 之間的有效連接埠", + "non_negative_integer": "請輸入非負整數", + "payload_invalid_number": "請輸入有效數字", + "payload_invalid_boolean": "請選擇 true 或 false", + "payload_invalid_json": "請輸入有效的 JSON" + }, + "common": { + "edit": "編輯", + "delete": "刪除", + "cancel": "取消", + "update": "更新", + "add": "新增" + } + } + }, + "quota_management": { + "title": "配額管理", + "description": "集中查看 OAuth 配額與剩餘情況", + "refresh_files": "重新整理驗證檔案", + "refresh_files_and_quota": "重新整理驗證檔案&配額", + "refresh_all_credentials": "重新整理全部憑證", + "card_idle_hint": "請使用頂部「重新整理全部憑證」按鈕取得最新配額。" + }, + "system_info": { + "title": "管理中心資訊", + "about_title": "CLI Proxy API Management Center", + "connection_status_title": "連線狀態", + "api_status_label": "API 狀態:", + "config_status_label": "設定狀態:", + "last_update_label": "最後更新:", + "cache_data": "快取資料", + "real_time_data": "即時資料", + "not_loaded": "未載入", + "seconds_ago": "秒前", + "models_title": "可用模型清單", + "models_desc": "展示 /models 回傳的模型,並自動使用伺服器儲存的 API Key 進行驗證。", + "models_loading": "正在載入可用模型...", + "models_empty": "未從 /models 取得到模型資料", + "models_error": "取得模型清單失敗", + "models_count": "可用模型 {{count}} 個", + "version_check_title": "版本檢查", + "version_check_desc": "呼叫 /latest-version 介面比對伺服器版本,提示是否有可用更新。", + "version_current_label": "目前版本", + "version_latest_label": "最新版本", + "version_check_button": "檢查更新", + "version_check_idle": "點擊檢查更新", + "version_checking": "正在檢查最新版本...", + "version_update_available": "有新版本可用:{{version}}", + "version_is_latest": "目前已是最新版本", + "version_check_error": "檢查更新失敗", + "version_current_missing": "未取得到伺服器版本號,暫無法比對", + "version_unknown": "未知", + "quick_links_title": "快速連結", + "quick_links_desc": "存取專案儲存庫和文件,取得協助和更新。", + "link_main_repo": "主程式儲存庫", + "link_main_repo_desc": "CLI Proxy API 核心程式原始碼", + "link_webui_repo": "WebUI 儲存庫", + "link_webui_repo_desc": "管理中心前端介面原始碼", + "link_docs": "使用教學", + "link_docs_desc": "設定指南和使用說明", + "clear_login_title": "本地登入資訊", + "clear_login_desc": "清理本地儲存的登入資訊並登出,不會影響使用統計中的定價設定。", + "clear_login_button": "清理登入資訊", + "clear_login_confirm": "確認清理本地登入資訊並登出?" + }, + "notification": { + "debug_updated": "除錯設定已更新", + "proxy_updated": "代理設定已更新", + "proxy_cleared": "代理設定已清空", + "retry_updated": "重試設定已更新", + "quota_switch_project_updated": "專案切換設定已更新", + "quota_switch_preview_updated": "預覽模型切換設定已更新", + "usage_statistics_updated": "使用統計設定已更新", + "logging_to_file_updated": "記錄設定已更新", + "logs_max_total_size_updated": "記錄容量設定已更新", + "request_log_updated": "請求記錄設定已更新", + "force_model_prefix_updated": "模型前綴設定已更新", + "ws_auth_updated": "WebSocket 驗證設定已更新", + "routing_strategy_updated": "路由策略已更新", + "login_storage_cleared": "本地登入資訊已清理", + "api_key_added": "API 金鑰新增成功", + "api_key_updated": "API 金鑰更新成功", + "api_key_deleted": "API 金鑰刪除成功", + "api_key_invalid_chars": "API 金鑰僅支援英文字母、數字和符號", + "gemini_key_added": "Gemini 金鑰新增成功", + "gemini_key_updated": "Gemini 金鑰更新成功", + "gemini_key_deleted": "Gemini 金鑰刪除成功", + "gemini_multi_input_required": "請先輸入至少一個 Gemini 金鑰", + "gemini_multi_failed": "Gemini 金鑰批次新增失敗", + "gemini_multi_summary": "Gemini 批次新增完成:成功 {{success}},跳過 {{skipped}},失敗 {{failed}}", + "codex_config_added": "Codex 設定新增成功", + "codex_config_updated": "Codex 設定更新成功", + "codex_config_deleted": "Codex 設定刪除成功", + "codex_base_url_required": "請填寫 Codex Base URL", + "claude_config_added": "Claude 設定新增成功", + "claude_config_updated": "Claude 設定更新成功", + "claude_config_deleted": "Claude 設定刪除成功", + "vertex_config_added": "Vertex 設定新增成功", + "vertex_config_updated": "Vertex 設定更新成功", + "vertex_config_deleted": "Vertex 設定刪除成功", + "config_enabled": "設定已啟用", + "config_disabled": "設定已停用", + "field_required": "必填欄位不能為空", + "openai_provider_required": "請填寫供應商名稱和 Base URL", + "openai_provider_added": "OpenAI 供應商新增成功", + "openai_provider_updated": "OpenAI 供應商更新成功", + "openai_provider_deleted": "OpenAI 供應商刪除成功", + "ampcode_updated": "Ampcode 設定已更新", + "ampcode_upstream_api_key_cleared": "Ampcode upstream API key 覆寫已清除", + "openai_model_name_required": "請填寫模型名稱", + "openai_test_url_required": "請先填寫有效的 Base URL 以進行測試", + "openai_test_key_required": "請至少填寫一個 API 金鑰以進行測試", + "openai_test_model_required": "請選擇要測試的模型", + "data_refreshed": "資料重新整理成功", + "connection_required": "請先建立連線", + "refresh_failed": "重新整理失敗", + "update_failed": "更新失敗", + "add_failed": "新增失敗", + "delete_failed": "刪除失敗", + "upload_failed": "上傳失敗", + "download_failed": "下載失敗", + "login_failed": "登入失敗", + "please_enter": "請輸入", + "please_fill": "請填寫", + "provider_name_url": "供應商名稱和 Base URL", + "api_key": "API 金鑰", + "gemini_api_key": "Gemini API 金鑰", + "codex_api_key": "Codex API 金鑰", + "claude_api_key": "Claude API 金鑰", + "commercial_mode_restart_required": "商業模式開關已變更,請重啟服務後生效", + "copy_failed": "複製失敗", + "link_copied": "已複製" + }, + "language": { + "switch": "語言", + "chinese": "簡體中文", + "chinese_tw": "繁體中文(台灣)", + "english": "英文", + "russian": "俄文" + }, + "theme": { + "switch": "主題", + "light": "羊毛紙", + "white": "純白", + "dark": "深色", + "switch_to_light": "切換到羊毛紙模式", + "switch_to_dark": "切換到深色模式", + "auto": "跟隨系統" + }, + "sidebar": { + "toggle_expand": "展開側邊欄", + "toggle_collapse": "收起側邊欄" + }, + "footer": { + "api_version": "CLI Proxy API 版本", + "build_date": "建置時間", + "version": "管理中心版本", + "author": "作者" + } +} diff --git a/src/types/common.ts b/src/types/common.ts index 7b7889b..e0b799d 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -4,7 +4,7 @@ export type Theme = 'light' | 'white' | 'dark' | 'auto'; -export type Language = 'zh-CN' | 'en' | 'ru'; +export type Language = 'zh-CN' | 'zh-TW' | 'en' | 'ru'; export type NotificationType = 'info' | 'success' | 'warning' | 'error'; diff --git a/src/utils/constants.ts b/src/utils/constants.ts index e3b36c5..b4a3c9e 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -40,9 +40,10 @@ export const STORAGE_KEY_SIDEBAR = 'cli-proxy-sidebar-collapsed'; export const STORAGE_KEY_AUTH_FILES_PAGE_SIZE = 'cli-proxy-auth-files-page-size'; // 语言配置 -export const LANGUAGE_ORDER = defineLanguageOrder(['zh-CN', 'en', 'ru'] as const); +export const LANGUAGE_ORDER = defineLanguageOrder(['zh-CN', 'zh-TW', 'en', 'ru'] as const); export const LANGUAGE_LABEL_KEYS: Record = { 'zh-CN': 'language.chinese', + 'zh-TW': 'language.chinese_tw', en: 'language.english', ru: 'language.russian' }; diff --git a/src/utils/language.ts b/src/utils/language.ts index ea8b2cc..314fe09 100644 --- a/src/utils/language.ts +++ b/src/utils/language.ts @@ -40,6 +40,7 @@ const getBrowserLanguage = (): Language => { } const raw = navigator.languages?.[0] || navigator.language || 'zh-CN'; const lower = raw.toLowerCase(); + if (lower.startsWith('zh-tw') || lower.startsWith('zh-hant')) return 'zh-TW'; if (lower.startsWith('zh')) return 'zh-CN'; if (lower.startsWith('ru')) return 'ru'; return 'en'; From 70a12bba4f4b8e6ab414fe7f0abf09b6e1f06f10 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Tue, 14 Apr 2026 23:22:20 +0800 Subject: [PATCH 2/8] fix(oauth): remove qwen oauth card --- src/i18n/locales/en.json | 11 ----------- src/i18n/locales/ru.json | 11 ----------- src/i18n/locales/zh-CN.json | 11 ----------- src/pages/OAuthPage.tsx | 4 +--- src/services/api/oauth.ts | 3 +-- src/types/oauth.ts | 3 +-- src/utils/constants.ts | 6 ++---- 7 files changed, 5 insertions(+), 44 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 18973d7..8d15c97 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -891,17 +891,6 @@ "kimi_oauth_status_error": "Authentication failed:", "kimi_oauth_start_error": "Failed to start Kimi OAuth:", "kimi_oauth_polling_error": "Failed to check authentication status:", - "qwen_oauth_title": "Qwen OAuth", - "qwen_oauth_button": "Start Qwen Login", - "qwen_oauth_hint": "Login to Qwen service through device authorization flow, automatically obtain and save authentication files.", - "qwen_oauth_url_label": "Authorization URL:", - "qwen_open_link": "Open Link", - "qwen_copy_link": "Copy Link", - "qwen_oauth_status_waiting": "Waiting for authentication...", - "qwen_oauth_status_success": "Authentication successful!", - "qwen_oauth_status_error": "Authentication failed:", - "qwen_oauth_start_error": "Failed to start Qwen OAuth:", - "qwen_oauth_polling_error": "Failed to check authentication status:", "oauth_callback_label": "Callback URL", "oauth_callback_placeholder": "http://localhost:1455/auth/callback?code=...&state=...", "oauth_callback_hint": "Remote browser mode: after the provider redirects to http://localhost:..., copy the full URL and submit it here.", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 452e8bb..dce50e1 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -888,17 +888,6 @@ "kimi_oauth_status_error": "Ошибка аутентификации:", "kimi_oauth_start_error": "Не удалось запустить Kimi OAuth:", "kimi_oauth_polling_error": "Не удалось проверить статус аутентификации:", - "qwen_oauth_title": "Qwen OAuth", - "qwen_oauth_button": "Начать вход Qwen", - "qwen_oauth_hint": "Выполните вход в сервис Qwen через поток авторизации устройства и автоматически получите/сохраните файлы авторизации.", - "qwen_oauth_url_label": "URL авторизации:", - "qwen_open_link": "Открыть ссылку", - "qwen_copy_link": "Скопировать ссылку", - "qwen_oauth_status_waiting": "Ожидание аутентификации...", - "qwen_oauth_status_success": "Аутентификация успешна!", - "qwen_oauth_status_error": "Ошибка аутентификации:", - "qwen_oauth_start_error": "Не удалось запустить Qwen OAuth:", - "qwen_oauth_polling_error": "Не удалось проверить статус аутентификации:", "oauth_callback_label": "Callback URL", "oauth_callback_placeholder": "http://localhost:1455/auth/callback?code=...&state=...", "oauth_callback_hint": "Режим удалённого браузера: после перенаправления провайдера на http://localhost:... скопируйте полный URL и отправьте его здесь.", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index f0f2d0d..c74221b 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -891,17 +891,6 @@ "kimi_oauth_status_error": "认证失败:", "kimi_oauth_start_error": "启动 Kimi OAuth 失败:", "kimi_oauth_polling_error": "检查认证状态失败:", - "qwen_oauth_title": "Qwen OAuth", - "qwen_oauth_button": "开始 Qwen 登录", - "qwen_oauth_hint": "通过设备授权流程登录 Qwen 服务,自动获取并保存认证文件。", - "qwen_oauth_url_label": "授权链接:", - "qwen_open_link": "打开链接", - "qwen_copy_link": "复制链接", - "qwen_oauth_status_waiting": "等待认证中...", - "qwen_oauth_status_success": "认证成功!", - "qwen_oauth_status_error": "认证失败:", - "qwen_oauth_start_error": "启动 Qwen OAuth 失败:", - "qwen_oauth_polling_error": "检查认证状态失败:", "oauth_callback_label": "回调 URL", "oauth_callback_placeholder": "http://localhost:1455/auth/callback?code=...&state=...", "oauth_callback_hint": "远程浏览器模式:当授权跳转到 http://localhost:... 后,复制完整 URL 并提交到这里。", diff --git a/src/pages/OAuthPage.tsx b/src/pages/OAuthPage.tsx index 12cd5e4..783a507 100644 --- a/src/pages/OAuthPage.tsx +++ b/src/pages/OAuthPage.tsx @@ -14,7 +14,6 @@ import iconAntigravity from '@/assets/icons/antigravity.svg'; import iconGemini from '@/assets/icons/gemini.svg'; import iconKimiLight from '@/assets/icons/kimi-light.svg'; import iconKimiDark from '@/assets/icons/kimi-dark.svg'; -import iconQwen from '@/assets/icons/qwen.svg'; import iconIflow from '@/assets/icons/iflow.svg'; import iconVertex from '@/assets/icons/vertex.svg'; @@ -76,8 +75,7 @@ const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabe { id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude }, { id: 'antigravity', titleKey: 'auth_login.antigravity_oauth_title', hintKey: 'auth_login.antigravity_oauth_hint', urlLabelKey: 'auth_login.antigravity_oauth_url_label', icon: iconAntigravity }, { id: 'gemini-cli', titleKey: 'auth_login.gemini_cli_oauth_title', hintKey: 'auth_login.gemini_cli_oauth_hint', urlLabelKey: 'auth_login.gemini_cli_oauth_url_label', icon: iconGemini }, - { id: 'kimi', titleKey: 'auth_login.kimi_oauth_title', hintKey: 'auth_login.kimi_oauth_hint', urlLabelKey: 'auth_login.kimi_oauth_url_label', icon: { light: iconKimiLight, dark: iconKimiDark } }, - { id: 'qwen', titleKey: 'auth_login.qwen_oauth_title', hintKey: 'auth_login.qwen_oauth_hint', urlLabelKey: 'auth_login.qwen_oauth_url_label', icon: iconQwen } + { id: 'kimi', titleKey: 'auth_login.kimi_oauth_title', hintKey: 'auth_login.kimi_oauth_hint', urlLabelKey: 'auth_login.kimi_oauth_url_label', icon: { light: iconKimiLight, dark: iconKimiDark } } ]; const CALLBACK_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli']; diff --git a/src/services/api/oauth.ts b/src/services/api/oauth.ts index 231d8d7..76cdd82 100644 --- a/src/services/api/oauth.ts +++ b/src/services/api/oauth.ts @@ -9,8 +9,7 @@ export type OAuthProvider = | 'anthropic' | 'antigravity' | 'gemini-cli' - | 'kimi' - | 'qwen'; + | 'kimi'; export interface OAuthStartResponse { url: string; diff --git a/src/types/oauth.ts b/src/types/oauth.ts index 524eb13..9803fc1 100644 --- a/src/types/oauth.ts +++ b/src/types/oauth.ts @@ -9,8 +9,7 @@ export type OAuthProvider = | 'anthropic' | 'antigravity' | 'gemini-cli' - | 'kimi' - | 'qwen'; + | 'kimi'; // OAuth 流程状态 export interface OAuthFlow { diff --git a/src/utils/constants.ts b/src/utils/constants.ts index e3b36c5..5859630 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -57,16 +57,14 @@ export const OAUTH_CARD_IDS = [ 'anthropic-oauth-card', 'antigravity-oauth-card', 'gemini-cli-oauth-card', - 'kimi-oauth-card', - 'qwen-oauth-card' + 'kimi-oauth-card' ]; export const OAUTH_PROVIDERS = { CODEX: 'codex', ANTHROPIC: 'anthropic', ANTIGRAVITY: 'antigravity', GEMINI_CLI: 'gemini-cli', - KIMI: 'kimi', - QWEN: 'qwen' + KIMI: 'kimi' } as const; // API 端点 From 74badcac8ba913c56fd7bb8a20bd010afe8e5d2c Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Tue, 14 Apr 2026 23:25:17 +0800 Subject: [PATCH 3/8] fix(oauth): remove iflow cookie login --- src/i18n/locales/en.json | 17 ----- src/i18n/locales/ru.json | 17 ----- src/i18n/locales/zh-CN.json | 17 ----- src/pages/OAuthPage.tsx | 126 +----------------------------------- src/services/api/oauth.ts | 15 +---- 5 files changed, 2 insertions(+), 190 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 8d15c97..94392bd 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -913,23 +913,6 @@ "iflow_oauth_status_error": "Authentication failed:", "iflow_oauth_start_error": "Failed to start iFlow OAuth:", "iflow_oauth_polling_error": "Failed to check authentication status:", - "iflow_cookie_title": "iFlow Cookie Login", - "iflow_cookie_label": "Cookie Value:", - "iflow_cookie_placeholder": "Enter the BXAuth value, starting with BXAuth=", - "iflow_cookie_hint": "Submit an existing cookie to finish login without opening the authorization link; the credential file will be saved automatically.", - "iflow_cookie_key_hint": "Note: Create a key on the platform first.", - "iflow_cookie_button": "Submit Cookie Login", - "iflow_cookie_status_success": "Cookie login succeeded and credentials are saved.", - "iflow_cookie_status_error": "Cookie login failed:", - "iflow_cookie_status_duplicate": "Duplicate config:", - "iflow_cookie_start_error": "Failed to submit cookie login:", - "iflow_cookie_config_duplicate": "A config file already exists (duplicate). Remove the existing file and try again if you want to re-save it.", - "iflow_cookie_required": "Please provide the Cookie value first.", - "iflow_cookie_result_title": "Cookie Login Result", - "iflow_cookie_result_email": "Account", - "iflow_cookie_result_expired": "Expires At", - "iflow_cookie_result_path": "Saved Path", - "iflow_cookie_result_type": "Type", "remote_access_disabled": "This login method is not available for remote access. Please access from localhost." }, "usage_stats": { diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index dce50e1..7f0d34e 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -910,23 +910,6 @@ "iflow_oauth_status_error": "Ошибка аутентификации:", "iflow_oauth_start_error": "Не удалось запустить iFlow OAuth:", "iflow_oauth_polling_error": "Не удалось проверить статус аутентификации:", - "iflow_cookie_title": "Вход iFlow по cookie", - "iflow_cookie_label": "Значение cookie:", - "iflow_cookie_placeholder": "Введите значение BXAuth, начиная с BXAuth=", - "iflow_cookie_hint": "Отправьте существующий cookie, чтобы завершить вход без открытия ссылки авторизации; файл учётных данных будет сохранён автоматически.", - "iflow_cookie_key_hint": "Примечание: сначала создайте ключ на платформе.", - "iflow_cookie_button": "Отправить вход по cookie", - "iflow_cookie_status_success": "Вход по cookie выполнен, учётные данные сохранены.", - "iflow_cookie_status_error": "Ошибка входа по cookie:", - "iflow_cookie_status_duplicate": "Дублирующая конфигурация:", - "iflow_cookie_start_error": "Не удалось отправить вход по cookie:", - "iflow_cookie_config_duplicate": "Такая конфигурация уже существует. Удалите файл и повторите, если хотите перезаписать.", - "iflow_cookie_required": "Сначала укажите значение cookie.", - "iflow_cookie_result_title": "Результат входа по cookie", - "iflow_cookie_result_email": "Аккаунт", - "iflow_cookie_result_expired": "Истекает", - "iflow_cookie_result_path": "Путь сохранения", - "iflow_cookie_result_type": "Тип", "remote_access_disabled": "Этот способ входа недоступен при удалённом доступе. Подключитесь с localhost." }, "usage_stats": { diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index c74221b..92ca7a6 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -913,23 +913,6 @@ "iflow_oauth_status_error": "认证失败:", "iflow_oauth_start_error": "启动 iFlow OAuth 失败:", "iflow_oauth_polling_error": "检查认证状态失败:", - "iflow_cookie_title": "iFlow Cookie 登录", - "iflow_cookie_label": "Cookie 内容:", - "iflow_cookie_placeholder": "填入BXAuth值 以BXAuth=开头", - "iflow_cookie_hint": "直接提交 Cookie 以完成登录(无需打开授权链接),服务端将自动保存凭据。", - "iflow_cookie_key_hint": "提示:需在平台上先创建 Key。", - "iflow_cookie_button": "提交 Cookie 登录", - "iflow_cookie_status_success": "Cookie 登录成功,凭据已保存。", - "iflow_cookie_status_error": "Cookie 登录失败:", - "iflow_cookie_status_duplicate": "配置文件重复:", - "iflow_cookie_start_error": "提交 Cookie 登录失败:", - "iflow_cookie_config_duplicate": "检测到配置文件已存在(重复),如需重新保存请先删除原文件后重试。", - "iflow_cookie_required": "请先填写 Cookie 内容", - "iflow_cookie_result_title": "Cookie 登录结果", - "iflow_cookie_result_email": "账号", - "iflow_cookie_result_expired": "过期时间", - "iflow_cookie_result_path": "保存路径", - "iflow_cookie_result_type": "类型", "remote_access_disabled": "远程访问不支持此登录方式,请从本地 (localhost) 访问" }, "usage_stats": { diff --git a/src/pages/OAuthPage.tsx b/src/pages/OAuthPage.tsx index 783a507..7166733 100644 --- a/src/pages/OAuthPage.tsx +++ b/src/pages/OAuthPage.tsx @@ -4,7 +4,7 @@ import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { useNotificationStore, useThemeStore } from '@/stores'; -import { oauthApi, type OAuthProvider, type IFlowCookieAuthResponse } from '@/services/api/oauth'; +import { oauthApi, type OAuthProvider } from '@/services/api/oauth'; import { vertexApi, type VertexImportResponse } from '@/services/api/vertex'; import { copyToClipboard } from '@/utils/clipboard'; import styles from './OAuthPage.module.scss'; @@ -14,7 +14,6 @@ import iconAntigravity from '@/assets/icons/antigravity.svg'; import iconGemini from '@/assets/icons/gemini.svg'; import iconKimiLight from '@/assets/icons/kimi-light.svg'; import iconKimiDark from '@/assets/icons/kimi-dark.svg'; -import iconIflow from '@/assets/icons/iflow.svg'; import iconVertex from '@/assets/icons/vertex.svg'; interface ProviderState { @@ -31,14 +30,6 @@ interface ProviderState { callbackError?: string; } -interface IFlowCookieState { - cookie: string; - loading: boolean; - result?: IFlowCookieAuthResponse; - error?: string; - errorType?: 'error' | 'warning'; -} - interface VertexImportResult { projectId?: string; email?: string; @@ -92,7 +83,6 @@ export function OAuthPage() { const { showNotification } = useNotificationStore(); const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const [states, setStates] = useState>({} as Record); - const [iflowCookie, setIflowCookie] = useState({ cookie: '', loading: false }); const [vertexState, setVertexState] = useState({ fileName: '', location: '', @@ -233,49 +223,6 @@ export function OAuthPage() { } }; - const submitIflowCookie = async () => { - const cookie = iflowCookie.cookie.trim(); - if (!cookie) { - showNotification(t('auth_login.iflow_cookie_required'), 'warning'); - return; - } - setIflowCookie((prev) => ({ - ...prev, - loading: true, - error: undefined, - errorType: undefined, - result: undefined - })); - try { - const res = await oauthApi.iflowCookieAuth(cookie); - if (res.status === 'ok') { - setIflowCookie((prev) => ({ ...prev, loading: false, result: res })); - showNotification(t('auth_login.iflow_cookie_status_success'), 'success'); - } else { - setIflowCookie((prev) => ({ - ...prev, - loading: false, - error: res.error, - errorType: 'error' - })); - showNotification(`${t('auth_login.iflow_cookie_status_error')} ${res.error || ''}`, 'error'); - } - } catch (err: unknown) { - if (getErrorStatus(err) === 409) { - const message = t('auth_login.iflow_cookie_config_duplicate'); - setIflowCookie((prev) => ({ ...prev, loading: false, error: message, errorType: 'warning' })); - showNotification(message, 'warning'); - return; - } - const message = getErrorMessage(err); - setIflowCookie((prev) => ({ ...prev, loading: false, error: message, errorType: 'error' })); - showNotification( - `${t('auth_login.iflow_cookie_start_error')}${message ? ` ${message}` : ''}`, - 'error' - ); - } - }; - const handleVertexFilePick = () => { vertexFileInputRef.current?.click(); }; @@ -540,77 +487,6 @@ export function OAuthPage() { )} - - {/* iFlow Cookie 登录 */} - - - {t('auth_login.iflow_cookie_title')} - - } - extra={ - - } - > -
-
{t('auth_login.iflow_cookie_hint')}
-
- {t('auth_login.iflow_cookie_key_hint')} -
-
- - setIflowCookie((prev) => ({ ...prev, cookie: e.target.value }))} - placeholder={t('auth_login.iflow_cookie_placeholder')} - /> -
- {iflowCookie.error && ( -
- {iflowCookie.errorType === 'warning' - ? t('auth_login.iflow_cookie_status_duplicate') - : t('auth_login.iflow_cookie_status_error')}{' '} - {iflowCookie.error} -
- )} - {iflowCookie.result && iflowCookie.result.status === 'ok' && ( -
-
{t('auth_login.iflow_cookie_result_title')}
-
- {iflowCookie.result.email && ( -
- {t('auth_login.iflow_cookie_result_email')} - {iflowCookie.result.email} -
- )} - {iflowCookie.result.expired && ( -
- {t('auth_login.iflow_cookie_result_expired')} - {iflowCookie.result.expired} -
- )} - {iflowCookie.result.saved_path && ( -
- {t('auth_login.iflow_cookie_result_path')} - {iflowCookie.result.saved_path} -
- )} - {iflowCookie.result.type && ( -
- {t('auth_login.iflow_cookie_result_type')} - {iflowCookie.result.type} -
- )} -
-
- )} -
-
); diff --git a/src/services/api/oauth.ts b/src/services/api/oauth.ts index 76cdd82..e421aa8 100644 --- a/src/services/api/oauth.ts +++ b/src/services/api/oauth.ts @@ -20,15 +20,6 @@ export interface OAuthCallbackResponse { status: 'ok'; } -export interface IFlowCookieAuthResponse { - status: 'ok' | 'error'; - error?: string; - saved_path?: string; - email?: string; - expired?: string; - type?: string; -} - const WEBUI_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli']; const CALLBACK_PROVIDER_MAP: Partial> = { 'gemini-cli': 'gemini' @@ -59,9 +50,5 @@ export const oauthApi = { provider: callbackProvider, redirect_url: redirectUrl }); - }, - - /** iFlow cookie 认证 */ - iflowCookieAuth: (cookie: string) => - apiClient.post('/iflow-auth-url', { cookie }) + } }; From 496f9900c6941d56e7632997914f110442ea23f2 Mon Sep 17 00:00:00 2001 From: xin Date: Wed, 15 Apr 2026 19:12:48 +0800 Subject: [PATCH 4/8] fix(usage): normalize high-precision RFC3339 timestamps --- .../usage/RequestEventsDetailsCard.tsx | 3 +- src/features/authFiles/constants.ts | 3 +- src/pages/hooks/useTraceResolver.ts | 3 +- src/services/api/authFiles.ts | 3 +- src/utils/format.ts | 6 +- src/utils/timestamp.ts | 58 +++++++++++++++++++ src/utils/usage.ts | 25 ++++---- 7 files changed, 83 insertions(+), 18 deletions(-) create mode 100644 src/utils/timestamp.ts diff --git a/src/components/usage/RequestEventsDetailsCard.tsx b/src/components/usage/RequestEventsDetailsCard.tsx index c5d13dc..2a69b22 100644 --- a/src/components/usage/RequestEventsDetailsCard.tsx +++ b/src/components/usage/RequestEventsDetailsCard.tsx @@ -9,6 +9,7 @@ import type { GeminiKeyConfig, ProviderKeyConfig, OpenAIProviderConfig } from '@ import type { AuthFileItem } from '@/types/authFile'; import type { CredentialInfo } from '@/types/sourceInfo'; import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver'; +import { parseTimestampMs } from '@/utils/timestamp'; import { collectUsageDetails, extractLatencyMs, @@ -131,7 +132,7 @@ export function RequestEventsDetailsCard({ const timestampMs = typeof detail.__timestampMs === 'number' && detail.__timestampMs > 0 ? detail.__timestampMs - : Date.parse(timestamp); + : parseTimestampMs(timestamp); const date = Number.isNaN(timestampMs) ? null : new Date(timestampMs); const sourceRaw = String(detail.source ?? '').trim(); const authIndexRaw = detail.auth_index as unknown; diff --git a/src/features/authFiles/constants.ts b/src/features/authFiles/constants.ts index 93e60c2..c8c1a4a 100644 --- a/src/features/authFiles/constants.ts +++ b/src/features/authFiles/constants.ts @@ -9,6 +9,7 @@ import iconKimiLight from '@/assets/icons/kimi-light.svg'; import iconQwen from '@/assets/icons/qwen.svg'; import iconVertex from '@/assets/icons/vertex.svg'; import type { AuthFileItem } from '@/types'; +import { parseTimestamp } from '@/utils/timestamp'; import { normalizeAuthIndex, normalizeUsageSourceId, @@ -279,7 +280,7 @@ export const formatModified = (item: AuthFileItem): string => { const date = Number.isFinite(asNumber) && !Number.isNaN(asNumber) ? new Date(asNumber < 1e12 ? asNumber * 1000 : asNumber) - : new Date(String(raw)); + : parseTimestamp(raw) ?? new Date(String(raw)); return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString(); }; diff --git a/src/pages/hooks/useTraceResolver.ts b/src/pages/hooks/useTraceResolver.ts index daab3f9..41da24e 100644 --- a/src/pages/hooks/useTraceResolver.ts +++ b/src/pages/hooks/useTraceResolver.ts @@ -5,6 +5,7 @@ import { USAGE_STATS_STALE_TIME_MS, useUsageStatsStore } from '@/stores'; import type { AuthFileItem, Config } from '@/types'; import type { CredentialInfo, SourceInfo } from '@/types/sourceInfo'; import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver'; +import { parseTimestampMs } from '@/utils/timestamp'; import { collectUsageDetailsWithEndpoint, normalizeAuthIndex, @@ -183,7 +184,7 @@ export function useTraceResolver(options: UseTraceResolverOptions): UseTraceReso if (!logPath) return []; const logTimestampMs = traceLogLine.timestamp - ? Date.parse(traceLogLine.timestamp) + ? parseTimestampMs(traceLogLine.timestamp) : Number.NaN; // Step 1: filter by path match diff --git a/src/services/api/authFiles.ts b/src/services/api/authFiles.ts index 5cce3d0..8494188 100644 --- a/src/services/api/authFiles.ts +++ b/src/services/api/authFiles.ts @@ -5,6 +5,7 @@ import { apiClient } from './client'; import type { AuthFilesResponse } from '@/types/authFile'; import type { OAuthModelAliasEntry } from '@/types'; +import { parseTimestampMs } from '@/utils/timestamp'; type StatusError = { status?: number }; type AuthFileStatusResponse = { status: string; disabled: boolean }; @@ -185,7 +186,7 @@ const readDateField = (entry: AuthFileEntry): number => { if (Number.isFinite(asNumber)) { return asNumber < 1e12 ? asNumber * 1000 : asNumber; } - const parsed = Date.parse(trimmed); + const parsed = parseTimestampMs(trimmed); if (!Number.isNaN(parsed)) { return parsed; } diff --git a/src/utils/format.ts b/src/utils/format.ts index f9eadf5..ba80460 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -1,3 +1,5 @@ +import { parseTimestamp } from './timestamp'; + /** * 格式化工具函数 * 从原项目 src/utils/string.js 迁移 @@ -47,7 +49,7 @@ export function formatFileSize(bytes: number): string { * 格式化日期时间 */ export function formatDateTime(date: string | Date, locale?: string): string { - const d = typeof date === 'string' ? new Date(date) : date; + const d = typeof date === 'string' ? parseTimestamp(date) ?? new Date(date) : date; if (isNaN(d.getTime())) { return 'Invalid Date'; @@ -73,7 +75,7 @@ export function formatUnixTimestamp(value: unknown, locale?: string): string { const asNumber = typeof value === 'number' ? value : Number(value); const date = (() => { if (!Number.isFinite(asNumber) || Number.isNaN(asNumber)) { - return new Date(String(value)); + return parseTimestamp(value) ?? new Date(String(value)); } const abs = Math.abs(asNumber); diff --git a/src/utils/timestamp.ts b/src/utils/timestamp.ts new file mode 100644 index 0000000..19eff5a --- /dev/null +++ b/src/utils/timestamp.ts @@ -0,0 +1,58 @@ +const RFC3339_HIGH_PRECISION_REGEX = + /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.(\d+))?(Z|[+-]\d{2}:\d{2})?$/i; + +/** + * Some browsers mis-handle RFC3339 timestamps that include sub-millisecond + * precision. Normalize them to millisecond precision before parsing. + */ +export function normalizeTimestampForDateParse(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return ''; + + const match = trimmed.match(RFC3339_HIGH_PRECISION_REGEX); + if (!match) return trimmed; + + const [, base, , fractionDigits = '', timezone = ''] = match; + if (fractionDigits.length <= 3) { + return trimmed; + } + + return `${base}.${fractionDigits.slice(0, 3)}${timezone}`; +} + +export function parseTimestampMs(value: unknown): number { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value !== 'string') { + return Number.NaN; + } + + const trimmed = value.trim(); + if (!trimmed) { + return Number.NaN; + } + + const normalized = normalizeTimestampForDateParse(trimmed); + const normalizedParsed = Date.parse(normalized); + if (!Number.isNaN(normalizedParsed)) { + return normalizedParsed; + } + + if (normalized !== trimmed) { + const originalParsed = Date.parse(trimmed); + if (!Number.isNaN(originalParsed)) { + return originalParsed; + } + } + + return Number.NaN; +} + +export function parseTimestamp(value: unknown): Date | null { + const timestampMs = parseTimestampMs(value); + if (!Number.isFinite(timestampMs)) { + return null; + } + return new Date(timestampMs); +} diff --git a/src/utils/usage.ts b/src/utils/usage.ts index a6827b5..8d53755 100644 --- a/src/utils/usage.ts +++ b/src/utils/usage.ts @@ -13,6 +13,7 @@ import { finalizeLatencyStats, } from './usage/latency'; import { maskApiKey } from './format'; +import { parseTimestampMs } from './timestamp'; export type { DurationFormatOptions, LatencyStats } from './usage/latency'; export { @@ -195,7 +196,7 @@ export function filterUsageByTimeRange( if (!detailRecord || typeof detailRecord.timestamp !== 'string') { return; } - const timestamp = Date.parse(detailRecord.timestamp); + const timestamp = parseTimestampMs(detailRecord.timestamp); if (Number.isNaN(timestamp) || timestamp < windowStart || timestamp > nowMs) { return; } @@ -545,7 +546,7 @@ export function collectUsageDetails(usageData: unknown): UsageDetail[] { modelDetails.forEach((detailRaw) => { if (!isRecord(detailRaw) || typeof detailRaw.timestamp !== 'string') return; const timestamp = detailRaw.timestamp; - const timestampMs = Date.parse(timestamp); + const timestampMs = parseTimestampMs(timestamp); const tokensRaw = isRecord(detailRaw.tokens) ? detailRaw.tokens : {}; const latencyMs = extractLatencyMs(detailRaw); details.push({ @@ -618,7 +619,7 @@ export function collectUsageDetailsWithEndpoint(usageData: unknown): UsageDetail modelDetails.forEach((detailRaw) => { if (!isRecord(detailRaw) || typeof detailRaw.timestamp !== 'string') return; const timestamp = detailRaw.timestamp; - const timestampMs = Date.parse(timestamp); + const timestampMs = parseTimestampMs(timestamp); const tokensRaw = isRecord(detailRaw.tokens) ? detailRaw.tokens : {}; const latencyMs = extractLatencyMs(detailRaw); details.push({ @@ -721,7 +722,7 @@ export function calculateRecentPerMinuteRates( const timestamp = typeof detail.__timestampMs === 'number' ? detail.__timestampMs - : Date.parse(detail.timestamp); + : parseTimestampMs(detail.timestamp); if (!Number.isFinite(timestamp) || timestamp < windowStart || timestamp > now) { return; } @@ -1131,7 +1132,7 @@ export function buildHourlySeriesByModel( const timestamp = typeof detail.__timestampMs === 'number' ? detail.__timestampMs - : Date.parse(detail.timestamp); + : parseTimestampMs(detail.timestamp); if (!Number.isFinite(timestamp) || timestamp <= 0) { return; } @@ -1190,7 +1191,7 @@ export function buildDailySeriesByModel( const timestamp = typeof detail.__timestampMs === 'number' ? detail.__timestampMs - : Date.parse(detail.timestamp); + : parseTimestampMs(detail.timestamp); if (!Number.isFinite(timestamp) || timestamp <= 0) { return; } @@ -1416,7 +1417,7 @@ export function calculateStatusBarData( const timestamp = typeof detail.__timestampMs === 'number' ? detail.__timestampMs - : Date.parse(detail.timestamp); + : parseTimestampMs(detail.timestamp); if ( !Number.isFinite(timestamp) || timestamp <= 0 || @@ -1524,7 +1525,7 @@ export function calculateServiceHealthData(usageDetails: UsageDetail[]): Service const timestamp = typeof detail.__timestampMs === 'number' ? detail.__timestampMs - : Date.parse(detail.timestamp); + : parseTimestampMs(detail.timestamp); if ( !Number.isFinite(timestamp) || timestamp <= 0 || @@ -1734,7 +1735,7 @@ export function buildHourlyTokenBreakdown( const timestamp = typeof detail.__timestampMs === 'number' ? detail.__timestampMs - : Date.parse(detail.timestamp); + : parseTimestampMs(detail.timestamp); if (!Number.isFinite(timestamp) || timestamp <= 0) return; const normalized = new Date(timestamp); normalized.setMinutes(0, 0, 0); @@ -1776,7 +1777,7 @@ export function buildDailyTokenBreakdown(usageData: unknown): TokenBreakdownSeri const timestamp = typeof detail.__timestampMs === 'number' ? detail.__timestampMs - : Date.parse(detail.timestamp); + : parseTimestampMs(detail.timestamp); if (!Number.isFinite(timestamp) || timestamp <= 0) return; const dayLabel = formatDayLabel(new Date(timestamp)); if (!dayLabel) return; @@ -1853,7 +1854,7 @@ export function buildHourlyCostSeries( const timestamp = typeof detail.__timestampMs === 'number' ? detail.__timestampMs - : Date.parse(detail.timestamp); + : parseTimestampMs(detail.timestamp); if (!Number.isFinite(timestamp) || timestamp <= 0) return; const normalized = new Date(timestamp); normalized.setMinutes(0, 0, 0); @@ -1888,7 +1889,7 @@ export function buildDailyCostSeries( const timestamp = typeof detail.__timestampMs === 'number' ? detail.__timestampMs - : Date.parse(detail.timestamp); + : parseTimestampMs(detail.timestamp); if (!Number.isFinite(timestamp) || timestamp <= 0) return; const dayLabel = formatDayLabel(new Date(timestamp)); if (!dayLabel) return; From 0c39e8d16f2a57ba50a6883ed6dc13fc6c9f0467 Mon Sep 17 00:00:00 2001 From: xin Date: Wed, 15 Apr 2026 19:55:50 +0800 Subject: [PATCH 5/8] fix(usage): handle Date inputs in timestamp parser --- src/utils/timestamp.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/utils/timestamp.ts b/src/utils/timestamp.ts index 19eff5a..04c5615 100644 --- a/src/utils/timestamp.ts +++ b/src/utils/timestamp.ts @@ -24,6 +24,9 @@ export function parseTimestampMs(value: unknown): number { if (typeof value === 'number' && Number.isFinite(value)) { return value; } + if (value instanceof Date) { + return value.getTime(); + } if (typeof value !== 'string') { return Number.NaN; } From 18544b79f9c1d26e1f521f0948f84f77f23cdd2b Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Fri, 17 Apr 2026 23:16:02 +0800 Subject: [PATCH 6/8] fix(i18n): map zh-HK and zh-MO to zh-TW --- src/utils/language.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/utils/language.ts b/src/utils/language.ts index 314fe09..67395ab 100644 --- a/src/utils/language.ts +++ b/src/utils/language.ts @@ -1,6 +1,8 @@ import type { Language } from '@/types'; import { STORAGE_KEY_LANGUAGE, SUPPORTED_LANGUAGES } from '@/utils/constants'; +const TRADITIONAL_CHINESE_PREFIXES = ['zh-tw', 'zh-hk', 'zh-mo', 'zh-hant'] as const; + export const isSupportedLanguage = (value: string): value is Language => SUPPORTED_LANGUAGES.includes(value as Language); @@ -40,7 +42,7 @@ const getBrowserLanguage = (): Language => { } const raw = navigator.languages?.[0] || navigator.language || 'zh-CN'; const lower = raw.toLowerCase(); - if (lower.startsWith('zh-tw') || lower.startsWith('zh-hant')) return 'zh-TW'; + if (TRADITIONAL_CHINESE_PREFIXES.some((prefix) => lower.startsWith(prefix))) return 'zh-TW'; if (lower.startsWith('zh')) return 'zh-CN'; if (lower.startsWith('ru')) return 'ru'; return 'en'; From a1401d40c0d1655a30dc441ee90b8b0a9ca101f8 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Sat, 18 Apr 2026 18:14:22 +0800 Subject: [PATCH 7/8] feat: enhance usage tracking with auth index support across providers --- .gitignore | 1 + .../providers/ClaudeSection/ClaudeSection.tsx | 52 ++--- .../providers/CodexSection/CodexSection.tsx | 52 ++--- .../providers/GeminiSection/GeminiSection.tsx | 50 +++-- .../providers/OpenAISection/OpenAISection.tsx | 62 +++--- .../providers/VertexSection/VertexSection.tsx | 50 +++-- src/components/providers/utils.ts | 188 ++++++++++++++++-- src/pages/AiProvidersPage.tsx | 11 +- src/services/api/transformers.ts | 25 ++- src/types/provider.ts | 4 + src/utils/usage.ts | 16 +- src/utils/usageIndex.ts | 38 +++- 12 files changed, 410 insertions(+), 139 deletions(-) diff --git a/.gitignore b/.gitignore index 02af05d..1f59de8 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ skills # Editor directories and files settings.local.json +.codex .vscode/* !.vscode/extensions.json .idea diff --git a/src/components/providers/ClaudeSection/ClaudeSection.tsx b/src/components/providers/ClaudeSection/ClaudeSection.tsx index 63ceb32..abb4a07 100644 --- a/src/components/providers/ClaudeSection/ClaudeSection.tsx +++ b/src/components/providers/ClaudeSection/ClaudeSection.tsx @@ -6,24 +6,23 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import iconClaude from '@/assets/icons/claude.svg'; import type { ProviderKeyConfig } from '@/types'; import { maskApiKey } from '@/utils/format'; -import { - buildCandidateUsageSourceIds, - calculateStatusBarData, - type KeyStats, -} from '@/utils/usage'; -import { - collectUsageDetailsForCandidates, - type UsageDetailsBySource, -} from '@/utils/usageIndex'; +import { calculateStatusBarData, type KeyStats } from '@/utils/usage'; +import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex'; import styles from '@/pages/AiProvidersPage.module.scss'; import { ProviderList } from '../ProviderList'; import { ProviderStatusBar } from '../ProviderStatusBar'; -import { getStatsBySource, hasDisableAllModelsRule } from '../utils'; +import { + collectUsageDetailsForIdentity, + getProviderConfigKey, + getStatsForIdentity, + hasDisableAllModelsRule, +} from '../utils'; interface ClaudeSectionProps { configs: ProviderKeyConfig[]; keyStats: KeyStats; usageDetailsBySource: UsageDetailsBySource; + usageDetailsByAuthIndex: UsageDetailsByAuthIndex; loading: boolean; disableControls: boolean; isSwitching: boolean; @@ -37,6 +36,7 @@ export function ClaudeSection({ configs, keyStats, usageDetailsBySource, + usageDetailsByAuthIndex, loading, disableControls, isSwitching, @@ -52,21 +52,23 @@ export function ClaudeSection({ const statusBarCache = useMemo(() => { const cache = new Map>(); - configs.forEach((config) => { + configs.forEach((config, index) => { if (!config.apiKey) return; - const candidates = buildCandidateUsageSourceIds({ - apiKey: config.apiKey, - prefix: config.prefix, - }); - if (!candidates.length) return; + const configKey = getProviderConfigKey(config, index); cache.set( - config.apiKey, - calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates)) + configKey, + calculateStatusBarData( + collectUsageDetailsForIdentity( + { authIndex: config.authIndex, apiKey: config.apiKey, prefix: config.prefix }, + usageDetailsBySource, + usageDetailsByAuthIndex + ) + ) ); }); return cache; - }, [configs, usageDetailsBySource]); + }, [configs, usageDetailsByAuthIndex, usageDetailsBySource]); return ( <> @@ -86,7 +88,7 @@ export function ClaudeSection({ items={configs} loading={loading} - keyField={(item) => item.apiKey} + keyField={(item, index) => getProviderConfigKey(item, index)} emptyTitle={t('ai_providers.claude_empty_title')} emptyDescription={t('ai_providers.claude_empty_desc')} onEdit={onEdit} @@ -101,12 +103,16 @@ export function ClaudeSection({ onChange={(value) => void onToggle(index, value)} /> )} - renderContent={(item) => { - const stats = getStatsBySource(item.apiKey, keyStats, item.prefix); + renderContent={(item, index) => { + const stats = getStatsForIdentity( + { authIndex: item.authIndex, apiKey: item.apiKey, prefix: item.prefix }, + keyStats + ); const headerEntries = Object.entries(item.headers || {}); const configDisabled = hasDisableAllModelsRule(item.excludedModels); const excludedModels = item.excludedModels ?? []; - const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]); + const statusData = + statusBarCache.get(getProviderConfigKey(item, index)) || calculateStatusBarData([]); return ( diff --git a/src/components/providers/CodexSection/CodexSection.tsx b/src/components/providers/CodexSection/CodexSection.tsx index ea31771..1245357 100644 --- a/src/components/providers/CodexSection/CodexSection.tsx +++ b/src/components/providers/CodexSection/CodexSection.tsx @@ -6,24 +6,23 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import iconCodex from '@/assets/icons/codex.svg'; import type { ProviderKeyConfig } from '@/types'; import { maskApiKey } from '@/utils/format'; -import { - buildCandidateUsageSourceIds, - calculateStatusBarData, - type KeyStats, -} from '@/utils/usage'; -import { - collectUsageDetailsForCandidates, - type UsageDetailsBySource, -} from '@/utils/usageIndex'; +import { calculateStatusBarData, type KeyStats } from '@/utils/usage'; +import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex'; import styles from '@/pages/AiProvidersPage.module.scss'; import { ProviderList } from '../ProviderList'; import { ProviderStatusBar } from '../ProviderStatusBar'; -import { getStatsBySource, hasDisableAllModelsRule } from '../utils'; +import { + collectUsageDetailsForIdentity, + getProviderConfigKey, + getStatsForIdentity, + hasDisableAllModelsRule, +} from '../utils'; interface CodexSectionProps { configs: ProviderKeyConfig[]; keyStats: KeyStats; usageDetailsBySource: UsageDetailsBySource; + usageDetailsByAuthIndex: UsageDetailsByAuthIndex; loading: boolean; disableControls: boolean; isSwitching: boolean; @@ -37,6 +36,7 @@ export function CodexSection({ configs, keyStats, usageDetailsBySource, + usageDetailsByAuthIndex, loading, disableControls, isSwitching, @@ -52,21 +52,23 @@ export function CodexSection({ const statusBarCache = useMemo(() => { const cache = new Map>(); - configs.forEach((config) => { + configs.forEach((config, index) => { if (!config.apiKey) return; - const candidates = buildCandidateUsageSourceIds({ - apiKey: config.apiKey, - prefix: config.prefix, - }); - if (!candidates.length) return; + const configKey = getProviderConfigKey(config, index); cache.set( - config.apiKey, - calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates)) + configKey, + calculateStatusBarData( + collectUsageDetailsForIdentity( + { authIndex: config.authIndex, apiKey: config.apiKey, prefix: config.prefix }, + usageDetailsBySource, + usageDetailsByAuthIndex + ) + ) ); }); return cache; - }, [configs, usageDetailsBySource]); + }, [configs, usageDetailsByAuthIndex, usageDetailsBySource]); return ( <> @@ -86,7 +88,7 @@ export function CodexSection({ items={configs} loading={loading} - keyField={(item) => item.apiKey} + keyField={(item, index) => getProviderConfigKey(item, index)} emptyTitle={t('ai_providers.codex_empty_title')} emptyDescription={t('ai_providers.codex_empty_desc')} onEdit={onEdit} @@ -101,12 +103,16 @@ export function CodexSection({ onChange={(value) => void onToggle(index, value)} /> )} - renderContent={(item) => { - const stats = getStatsBySource(item.apiKey, keyStats, item.prefix); + renderContent={(item, index) => { + const stats = getStatsForIdentity( + { authIndex: item.authIndex, apiKey: item.apiKey, prefix: item.prefix }, + keyStats + ); const headerEntries = Object.entries(item.headers || {}); const configDisabled = hasDisableAllModelsRule(item.excludedModels); const excludedModels = item.excludedModels ?? []; - const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]); + const statusData = + statusBarCache.get(getProviderConfigKey(item, index)) || calculateStatusBarData([]); return ( diff --git a/src/components/providers/GeminiSection/GeminiSection.tsx b/src/components/providers/GeminiSection/GeminiSection.tsx index 6f28f3e..a551311 100644 --- a/src/components/providers/GeminiSection/GeminiSection.tsx +++ b/src/components/providers/GeminiSection/GeminiSection.tsx @@ -6,24 +6,23 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import iconGemini from '@/assets/icons/gemini.svg'; import type { GeminiKeyConfig } from '@/types'; import { maskApiKey } from '@/utils/format'; -import { - buildCandidateUsageSourceIds, - calculateStatusBarData, - type KeyStats, -} from '@/utils/usage'; -import { - collectUsageDetailsForCandidates, - type UsageDetailsBySource, -} from '@/utils/usageIndex'; +import { calculateStatusBarData, type KeyStats } from '@/utils/usage'; +import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex'; import styles from '@/pages/AiProvidersPage.module.scss'; import { ProviderList } from '../ProviderList'; import { ProviderStatusBar } from '../ProviderStatusBar'; -import { getStatsBySource, hasDisableAllModelsRule } from '../utils'; +import { + collectUsageDetailsForIdentity, + getProviderConfigKey, + getStatsForIdentity, + hasDisableAllModelsRule, +} from '../utils'; interface GeminiSectionProps { configs: GeminiKeyConfig[]; keyStats: KeyStats; usageDetailsBySource: UsageDetailsBySource; + usageDetailsByAuthIndex: UsageDetailsByAuthIndex; loading: boolean; disableControls: boolean; isSwitching: boolean; @@ -37,6 +36,7 @@ export function GeminiSection({ configs, keyStats, usageDetailsBySource, + usageDetailsByAuthIndex, loading, disableControls, isSwitching, @@ -52,21 +52,23 @@ export function GeminiSection({ const statusBarCache = useMemo(() => { const cache = new Map>(); - configs.forEach((config) => { + configs.forEach((config, index) => { if (!config.apiKey) return; - const candidates = buildCandidateUsageSourceIds({ - apiKey: config.apiKey, - prefix: config.prefix, - }); - if (!candidates.length) return; + const configKey = getProviderConfigKey(config, index); cache.set( - config.apiKey, - calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates)) + configKey, + calculateStatusBarData( + collectUsageDetailsForIdentity( + { authIndex: config.authIndex, apiKey: config.apiKey, prefix: config.prefix }, + usageDetailsBySource, + usageDetailsByAuthIndex + ) + ) ); }); return cache; - }, [configs, usageDetailsBySource]); + }, [configs, usageDetailsByAuthIndex, usageDetailsBySource]); return ( <> @@ -86,7 +88,7 @@ export function GeminiSection({ items={configs} loading={loading} - keyField={(item) => item.apiKey} + keyField={(item, index) => getProviderConfigKey(item, index)} emptyTitle={t('ai_providers.gemini_empty_title')} emptyDescription={t('ai_providers.gemini_empty_desc')} onEdit={onEdit} @@ -102,11 +104,15 @@ export function GeminiSection({ /> )} renderContent={(item, index) => { - const stats = getStatsBySource(item.apiKey, keyStats, item.prefix); + const stats = getStatsForIdentity( + { authIndex: item.authIndex, apiKey: item.apiKey, prefix: item.prefix }, + keyStats + ); const headerEntries = Object.entries(item.headers || {}); const configDisabled = hasDisableAllModelsRule(item.excludedModels); const excludedModels = item.excludedModels ?? []; - const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]); + const statusData = + statusBarCache.get(getProviderConfigKey(item, index)) || calculateStatusBarData([]); return ( diff --git a/src/components/providers/OpenAISection/OpenAISection.tsx b/src/components/providers/OpenAISection/OpenAISection.tsx index 1782c72..6b9315d 100644 --- a/src/components/providers/OpenAISection/OpenAISection.tsx +++ b/src/components/providers/OpenAISection/OpenAISection.tsx @@ -7,21 +7,24 @@ import iconOpenaiLight from '@/assets/icons/openai-light.svg'; import iconOpenaiDark from '@/assets/icons/openai-dark.svg'; import type { OpenAIProviderConfig } from '@/types'; import { maskApiKey } from '@/utils/format'; -import { - buildCandidateUsageSourceIds, - calculateStatusBarData, - type KeyStats, -} from '@/utils/usage'; -import { collectUsageDetailsForCandidates, type UsageDetailsBySource } from '@/utils/usageIndex'; +import { calculateStatusBarData, type KeyStats } from '@/utils/usage'; +import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex'; import styles from '@/pages/AiProvidersPage.module.scss'; import { ProviderList } from '../ProviderList'; import { ProviderStatusBar } from '../ProviderStatusBar'; -import { getOpenAIProviderStats, getStatsBySource } from '../utils'; +import { + collectOpenAIProviderUsageDetails, + getOpenAIEntryKey, + getOpenAIProviderKey, + getOpenAIProviderStats, + getStatsForIdentity, +} from '../utils'; interface OpenAISectionProps { configs: OpenAIProviderConfig[]; keyStats: KeyStats; usageDetailsBySource: UsageDetailsBySource; + usageDetailsByAuthIndex: UsageDetailsByAuthIndex; loading: boolean; disableControls: boolean; isSwitching: boolean; @@ -35,6 +38,7 @@ export function OpenAISection({ configs, keyStats, usageDetailsBySource, + usageDetailsByAuthIndex, loading, disableControls, isSwitching, @@ -49,21 +53,22 @@ export function OpenAISection({ const statusBarCache = useMemo(() => { const cache = new Map>(); - configs.forEach((provider) => { - const sourceIds = new Set(); - buildCandidateUsageSourceIds({ prefix: provider.prefix }).forEach((id) => sourceIds.add(id)); - (provider.apiKeyEntries || []).forEach((entry) => { - buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => sourceIds.add(id)); - }); - - const filteredDetails = sourceIds.size - ? collectUsageDetailsForCandidates(usageDetailsBySource, sourceIds) - : []; - cache.set(provider.name, calculateStatusBarData(filteredDetails)); + configs.forEach((provider, index) => { + const providerKey = getOpenAIProviderKey(provider, index); + cache.set( + providerKey, + calculateStatusBarData( + collectOpenAIProviderUsageDetails( + provider, + usageDetailsBySource, + usageDetailsByAuthIndex + ) + ) + ); }); return cache; - }, [configs, usageDetailsBySource]); + }, [configs, usageDetailsByAuthIndex, usageDetailsBySource]); return ( <> @@ -87,17 +92,18 @@ export function OpenAISection({ items={configs} loading={loading} - keyField={(_, index) => `openai-provider-${index}`} + keyField={(item, index) => getOpenAIProviderKey(item, index)} emptyTitle={t('ai_providers.openai_empty_title')} emptyDescription={t('ai_providers.openai_empty_desc')} onEdit={onEdit} onDelete={onDelete} actionsDisabled={actionsDisabled} - renderContent={(item) => { - const stats = getOpenAIProviderStats(item.apiKeyEntries, keyStats, item.prefix); + renderContent={(item, index) => { + const stats = getOpenAIProviderStats(item, keyStats); const headerEntries = Object.entries(item.headers || {}); const apiKeyEntries = item.apiKeyEntries || []; - const statusData = statusBarCache.get(item.name) || calculateStatusBarData([]); + const statusData = + statusBarCache.get(getOpenAIProviderKey(item, index)) || calculateStatusBarData([]); return ( @@ -134,9 +140,15 @@ export function OpenAISection({
{apiKeyEntries.map((entry, entryIndex) => { - const entryStats = getStatsBySource(entry.apiKey, keyStats); + const entryStats = getStatsForIdentity( + { authIndex: entry.authIndex, apiKey: entry.apiKey }, + keyStats + ); return ( -
+
{entryIndex + 1} {maskApiKey(entry.apiKey)} {entry.proxyUrl && ( diff --git a/src/components/providers/VertexSection/VertexSection.tsx b/src/components/providers/VertexSection/VertexSection.tsx index 381dc9f..716d746 100644 --- a/src/components/providers/VertexSection/VertexSection.tsx +++ b/src/components/providers/VertexSection/VertexSection.tsx @@ -6,24 +6,23 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import iconVertex from '@/assets/icons/vertex.svg'; import type { ProviderKeyConfig } from '@/types'; import { maskApiKey } from '@/utils/format'; -import { - buildCandidateUsageSourceIds, - calculateStatusBarData, - type KeyStats, -} from '@/utils/usage'; -import { - collectUsageDetailsForCandidates, - type UsageDetailsBySource, -} from '@/utils/usageIndex'; +import { calculateStatusBarData, type KeyStats } from '@/utils/usage'; +import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex'; import styles from '@/pages/AiProvidersPage.module.scss'; import { ProviderList } from '../ProviderList'; import { ProviderStatusBar } from '../ProviderStatusBar'; -import { getStatsBySource, hasDisableAllModelsRule } from '../utils'; +import { + collectUsageDetailsForIdentity, + getProviderConfigKey, + getStatsForIdentity, + hasDisableAllModelsRule, +} from '../utils'; interface VertexSectionProps { configs: ProviderKeyConfig[]; keyStats: KeyStats; usageDetailsBySource: UsageDetailsBySource; + usageDetailsByAuthIndex: UsageDetailsByAuthIndex; loading: boolean; disableControls: boolean; isSwitching: boolean; @@ -37,6 +36,7 @@ export function VertexSection({ configs, keyStats, usageDetailsBySource, + usageDetailsByAuthIndex, loading, disableControls, isSwitching, @@ -52,21 +52,23 @@ export function VertexSection({ const statusBarCache = useMemo(() => { const cache = new Map>(); - configs.forEach((config) => { + configs.forEach((config, index) => { if (!config.apiKey) return; - const candidates = buildCandidateUsageSourceIds({ - apiKey: config.apiKey, - prefix: config.prefix, - }); - if (!candidates.length) return; + const configKey = getProviderConfigKey(config, index); cache.set( - config.apiKey, - calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates)) + configKey, + calculateStatusBarData( + collectUsageDetailsForIdentity( + { authIndex: config.authIndex, apiKey: config.apiKey, prefix: config.prefix }, + usageDetailsBySource, + usageDetailsByAuthIndex + ) + ) ); }); return cache; - }, [configs, usageDetailsBySource]); + }, [configs, usageDetailsByAuthIndex, usageDetailsBySource]); return ( <> @@ -86,7 +88,7 @@ export function VertexSection({ items={configs} loading={loading} - keyField={(item) => item.apiKey} + keyField={(item, index) => getProviderConfigKey(item, index)} emptyTitle={t('ai_providers.vertex_empty_title')} emptyDescription={t('ai_providers.vertex_empty_desc')} onEdit={onEdit} @@ -102,11 +104,15 @@ export function VertexSection({ /> )} renderContent={(item, index) => { - const stats = getStatsBySource(item.apiKey, keyStats, item.prefix); + const stats = getStatsForIdentity( + { authIndex: item.authIndex, apiKey: item.apiKey, prefix: item.prefix }, + keyStats + ); const headerEntries = Object.entries(item.headers || {}); const configDisabled = hasDisableAllModelsRule(item.excludedModels); const excludedModels = item.excludedModels ?? []; - const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]); + const statusData = + statusBarCache.get(getProviderConfigKey(item, index)) || calculateStatusBarData([]); return ( diff --git a/src/components/providers/utils.ts b/src/components/providers/utils.ts index fb07cb6..fbce115 100644 --- a/src/components/providers/utils.ts +++ b/src/components/providers/utils.ts @@ -1,5 +1,23 @@ -import type { AmpcodeConfig, AmpcodeModelMapping, AmpcodeUpstreamApiKeyMapping, ApiKeyEntry } from '@/types'; -import { buildCandidateUsageSourceIds, type KeyStatBucket, type KeyStats } from '@/utils/usage'; +import type { + AmpcodeConfig, + AmpcodeModelMapping, + AmpcodeUpstreamApiKeyMapping, + ApiKeyEntry, + OpenAIProviderConfig, +} from '@/types'; +import { + buildCandidateUsageSourceIds, + normalizeAuthIndex, + type KeyStatBucket, + type KeyStats, + type UsageDetail, +} from '@/utils/usage'; +import { + collectUsageDetailsForAuthIndices, + collectUsageDetailsForCandidates, + type UsageDetailsByAuthIndex, + type UsageDetailsBySource, +} from '@/utils/usageIndex'; import type { AmpcodeFormState, AmpcodeUpstreamApiKeyEntry, ModelEntry } from './types'; export const DISABLE_ALL_MODELS_RULE = '*'; @@ -109,25 +127,94 @@ export const getStatsBySource = ( return { success, failure }; }; -// 对于 OpenAI 提供商,汇总所有 apiKeyEntries 的统计 - 与旧版逻辑一致 -export const getOpenAIProviderStats = ( - apiKeyEntries: ApiKeyEntry[] | undefined, - keyStats: KeyStats, - providerPrefix?: string -): KeyStatBucket => { - const bySource = keyStats.bySource ?? {}; +type UsageIdentity = { + authIndex?: unknown; + apiKey?: string; + prefix?: string; +}; - const sourceIds = new Set(); - buildCandidateUsageSourceIds({ prefix: providerPrefix }).forEach((id) => sourceIds.add(id)); - (apiKeyEntries || []).forEach((entry) => { - buildCandidateUsageSourceIds({ apiKey: entry?.apiKey }).forEach((id) => sourceIds.add(id)); +export const getStatsForIdentity = ( + identity: UsageIdentity, + keyStats: KeyStats +): KeyStatBucket => { + const authIndexKey = normalizeAuthIndex(identity.authIndex); + if (authIndexKey) { + const stats = keyStats.byAuthIndex?.[authIndexKey]; + if (stats) { + return { success: stats.success, failure: stats.failure }; + } + } + + return getStatsBySource(identity.apiKey ?? '', keyStats, identity.prefix); +}; + +export const collectUsageDetailsForIdentity = ( + identity: UsageIdentity, + usageDetailsBySource: UsageDetailsBySource, + usageDetailsByAuthIndex: UsageDetailsByAuthIndex +): UsageDetail[] => { + const authIndexKey = normalizeAuthIndex(identity.authIndex); + if (authIndexKey) { + const details = collectUsageDetailsForAuthIndices(usageDetailsByAuthIndex, [authIndexKey]); + if (details.length > 0) { + return details; + } + } + + const candidates = buildCandidateUsageSourceIds({ + apiKey: identity.apiKey, + prefix: identity.prefix, + }); + if (!candidates.length) { + return []; + } + + return collectUsageDetailsForCandidates(usageDetailsBySource, candidates); +}; + +const mergeUsageDetails = (groups: UsageDetail[][]): UsageDetail[] => { + let firstDetails: UsageDetail[] | null = null; + let merged: UsageDetail[] | null = null; + + groups.forEach((details) => { + if (!details.length) return; + if (!firstDetails) { + firstDetails = details; + return; + } + if (!merged) { + merged = [...firstDetails]; + } + merged.push(...details); }); + return merged ?? firstDetails ?? []; +}; + +// 对于 OpenAI 提供商,汇总所有 apiKeyEntries 的统计 - 与旧版逻辑一致 +export const getOpenAIProviderStats = ( + provider: OpenAIProviderConfig, + keyStats: KeyStats +): KeyStatBucket => { let success = 0; let failure = 0; - sourceIds.forEach((id) => { - const stats = bySource[id]; - if (!stats) return; + + if (!provider.apiKeyEntries?.length) { + const stats = getStatsForIdentity( + { authIndex: provider.authIndex, prefix: provider.prefix }, + keyStats + ); + return { success: stats.success, failure: stats.failure }; + } + + if (!normalizeAuthIndex(provider.authIndex) && provider.prefix) { + const prefixStats = getStatsBySource('', keyStats, provider.prefix); + success += prefixStats.success; + failure += prefixStats.failure; + } + + provider.apiKeyEntries.forEach((entry) => { + const stats = getStatsForIdentity({ authIndex: entry.authIndex, apiKey: entry.apiKey }, keyStats); success += stats.success; failure += stats.failure; }); @@ -135,6 +222,75 @@ export const getOpenAIProviderStats = ( return { success, failure }; }; +export const collectOpenAIProviderUsageDetails = ( + provider: OpenAIProviderConfig, + usageDetailsBySource: UsageDetailsBySource, + usageDetailsByAuthIndex: UsageDetailsByAuthIndex +): UsageDetail[] => { + if (!provider.apiKeyEntries?.length) { + return collectUsageDetailsForIdentity( + { authIndex: provider.authIndex, prefix: provider.prefix }, + usageDetailsBySource, + usageDetailsByAuthIndex + ); + } + + const groups: UsageDetail[][] = []; + if (!normalizeAuthIndex(provider.authIndex) && provider.prefix) { + groups.push( + collectUsageDetailsForIdentity( + { prefix: provider.prefix }, + usageDetailsBySource, + usageDetailsByAuthIndex + ) + ); + } + + provider.apiKeyEntries.forEach((entry) => { + groups.push( + collectUsageDetailsForIdentity( + { authIndex: entry.authIndex, apiKey: entry.apiKey }, + usageDetailsBySource, + usageDetailsByAuthIndex + ) + ); + }); + + return mergeUsageDetails(groups); +}; + +export const getProviderConfigKey = ( + config: { + authIndex?: unknown; + apiKey?: string; + baseUrl?: string; + proxyUrl?: string; + }, + index: number +): string => { + const authIndexKey = normalizeAuthIndex(config.authIndex); + if (authIndexKey) { + return authIndexKey; + } + return `${config.apiKey ?? ''}::${config.baseUrl ?? ''}::${config.proxyUrl ?? ''}::${index}`; +}; + +export const getOpenAIProviderKey = (provider: OpenAIProviderConfig, index: number): string => { + const authIndexKey = normalizeAuthIndex(provider.authIndex); + if (authIndexKey) { + return authIndexKey; + } + return `${provider.name}::${provider.baseUrl}::${provider.prefix ?? ''}::${index}`; +}; + +export const getOpenAIEntryKey = (entry: ApiKeyEntry, index: number): string => { + const authIndexKey = normalizeAuthIndex(entry.authIndex); + if (authIndexKey) { + return authIndexKey; + } + return `${entry.apiKey}::${entry.proxyUrl ?? ''}::${index}`; +}; + export const buildApiKeyEntry = (input?: Partial): ApiKeyEntry => ({ apiKey: input?.apiKey ?? '', proxyUrl: input?.proxyUrl ?? '', diff --git a/src/pages/AiProvidersPage.tsx b/src/pages/AiProvidersPage.tsx index 821657d..8a3c437 100644 --- a/src/pages/AiProvidersPage.tsx +++ b/src/pages/AiProvidersPage.tsx @@ -20,7 +20,7 @@ import { useHeaderRefresh } from '@/hooks/useHeaderRefresh'; import { ampcodeApi, providersApi } from '@/services/api'; import { useAuthStore, useConfigStore, useNotificationStore, useThemeStore } from '@/stores'; import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types'; -import { indexUsageDetailsBySource } from '@/utils/usageIndex'; +import { indexUsageDetailsByAuthIndex, indexUsageDetailsBySource } from '@/utils/usageIndex'; import styles from './AiProvidersPage.module.scss'; export function AiProvidersPage() { @@ -71,6 +71,10 @@ export function AiProvidersPage() { () => indexUsageDetailsBySource(usageDetails), [usageDetails] ); + const usageDetailsByAuthIndex = useMemo( + () => indexUsageDetailsByAuthIndex(usageDetails), + [usageDetails] + ); const getErrorMessage = (err: unknown) => { if (err instanceof Error) return err.message; @@ -378,6 +382,7 @@ export function AiProvidersPage() { configs={geminiKeys} keyStats={keyStats} usageDetailsBySource={usageDetailsBySource} + usageDetailsByAuthIndex={usageDetailsByAuthIndex} loading={loading} disableControls={disableControls} isSwitching={isSwitching} @@ -393,6 +398,7 @@ export function AiProvidersPage() { configs={codexConfigs} keyStats={keyStats} usageDetailsBySource={usageDetailsBySource} + usageDetailsByAuthIndex={usageDetailsByAuthIndex} loading={loading} disableControls={disableControls} isSwitching={isSwitching} @@ -408,6 +414,7 @@ export function AiProvidersPage() { configs={claudeConfigs} keyStats={keyStats} usageDetailsBySource={usageDetailsBySource} + usageDetailsByAuthIndex={usageDetailsByAuthIndex} loading={loading} disableControls={disableControls} isSwitching={isSwitching} @@ -423,6 +430,7 @@ export function AiProvidersPage() { configs={vertexConfigs} keyStats={keyStats} usageDetailsBySource={usageDetailsBySource} + usageDetailsByAuthIndex={usageDetailsByAuthIndex} loading={loading} disableControls={disableControls} isSwitching={isSwitching} @@ -448,6 +456,7 @@ export function AiProvidersPage() { configs={openaiProviders} keyStats={keyStats} usageDetailsBySource={usageDetailsBySource} + usageDetailsByAuthIndex={usageDetailsByAuthIndex} loading={loading} disableControls={disableControls} isSwitching={isSwitching} diff --git a/src/services/api/transformers.ts b/src/services/api/transformers.ts index 929851d..8cef6f0 100644 --- a/src/services/api/transformers.ts +++ b/src/services/api/transformers.ts @@ -94,6 +94,12 @@ const normalizePrefix = (value: unknown): string | undefined => { return trimmed ? trimmed : undefined; }; +const normalizeAuthIndex = (value: unknown): string | undefined => { + if (value === undefined || value === null) return undefined; + const trimmed = String(value).trim(); + return trimmed ? trimmed : undefined; +}; + const normalizeApiKeyEntry = (entry: unknown): ApiKeyEntry | null => { if (entry === undefined || entry === null) return null; const record = isRecord(entry) ? entry : null; @@ -104,12 +110,17 @@ const normalizeApiKeyEntry = (entry: unknown): ApiKeyEntry | null => { const proxyUrl = record ? record['proxy-url'] ?? record.proxyUrl : undefined; const headers = record ? normalizeHeaders(record.headers) : undefined; + const authIndex = normalizeAuthIndex( + record?.['auth-index'] ?? record?.authIndex ?? record?.['auth_index'] + ); - return { + const result: ApiKeyEntry = { apiKey: trimmed, proxyUrl: proxyUrl ? String(proxyUrl) : undefined, headers }; + if (authIndex) result.authIndex = authIndex; + return result; }; const normalizeProviderKeyConfig = (item: unknown): ProviderKeyConfig | null => { @@ -146,6 +157,10 @@ const normalizeProviderKeyConfig = (item: unknown): ProviderKeyConfig | null => record?.excluded_models ); if (excludedModels.length) config.excludedModels = excludedModels; + const authIndex = normalizeAuthIndex( + record?.['auth-index'] ?? record?.authIndex ?? record?.['auth_index'] + ); + if (authIndex) config.authIndex = authIndex; const cloakRaw = record?.cloak; if (isRecord(cloakRaw)) { @@ -204,6 +219,10 @@ const normalizeGeminiKeyConfig = (item: unknown): GeminiKeyConfig | null => { if (headers) config.headers = headers; const excludedModels = normalizeExcludedModels(record?.['excluded-models'] ?? record?.excludedModels); if (excludedModels.length) config.excludedModels = excludedModels; + const authIndex = normalizeAuthIndex( + record?.['auth-index'] ?? record?.authIndex ?? record?.['auth_index'] + ); + if (authIndex) config.authIndex = authIndex; return config; }; @@ -241,6 +260,10 @@ const normalizeOpenAIProvider = (provider: unknown): OpenAIProviderConfig | null if (models.length) result.models = models; if (priority !== undefined) result.priority = Number(priority); if (testModel) result.testModel = String(testModel); + const authIndex = normalizeAuthIndex( + provider['auth-index'] ?? provider.authIndex ?? provider['auth_index'] + ); + if (authIndex) result.authIndex = authIndex; return result; }; diff --git a/src/types/provider.ts b/src/types/provider.ts index fde6dec..5dcb0c5 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -14,6 +14,7 @@ export interface ApiKeyEntry { apiKey: string; proxyUrl?: string; headers?: Record; + authIndex?: string; } export interface CloakConfig { @@ -31,6 +32,7 @@ export interface GeminiKeyConfig { models?: ModelAlias[]; headers?: Record; excludedModels?: string[]; + authIndex?: string; } export interface ProviderKeyConfig { @@ -44,6 +46,7 @@ export interface ProviderKeyConfig { models?: ModelAlias[]; excludedModels?: string[]; cloak?: CloakConfig; + authIndex?: string; } export interface OpenAIProviderConfig { @@ -55,5 +58,6 @@ export interface OpenAIProviderConfig { models?: ModelAlias[]; priority?: number; testModel?: string; + authIndex?: string; [key: string]: unknown; } diff --git a/src/utils/usage.ts b/src/utils/usage.ts index 8d53755..3515044 100644 --- a/src/utils/usage.ts +++ b/src/utils/usage.ts @@ -56,7 +56,7 @@ export interface ModelPrice { export interface UsageDetail { timestamp: string; source: string; - auth_index: number; + auth_index: string | number | null; latency_ms?: number; tokens: { input_tokens: number; @@ -552,7 +552,11 @@ export function collectUsageDetails(usageData: unknown): UsageDetail[] { details.push({ timestamp, source: normalizeSource(detailRaw.source), - auth_index: detailRaw.auth_index as unknown as number, + auth_index: + (detailRaw?.auth_index ?? + detailRaw?.authIndex ?? + detailRaw?.AuthIndex ?? + null) as UsageDetail['auth_index'], latency_ms: latencyMs ?? undefined, tokens: tokensRaw as unknown as UsageDetail['tokens'], failed: detailRaw.failed === true, @@ -625,7 +629,11 @@ export function collectUsageDetailsWithEndpoint(usageData: unknown): UsageDetail details.push({ timestamp, source: normalizeSource(detailRaw.source), - auth_index: detailRaw.auth_index as unknown as number, + auth_index: + (detailRaw?.auth_index ?? + detailRaw?.authIndex ?? + detailRaw?.AuthIndex ?? + null) as UsageDetail['auth_index'], latency_ms: latencyMs ?? undefined, tokens: tokensRaw as unknown as UsageDetail['tokens'], failed: detailRaw.failed === true, @@ -1394,7 +1402,7 @@ export interface StatusBarData { export function calculateStatusBarData( usageDetails: UsageDetail[], sourceFilter?: string, - authIndexFilter?: number + authIndexFilter?: string | number ): StatusBarData { const BLOCK_COUNT = 20; const BLOCK_DURATION_MS = 10 * 60 * 1000; // 10 minutes diff --git a/src/utils/usageIndex.ts b/src/utils/usageIndex.ts index 632f9f5..959812e 100644 --- a/src/utils/usageIndex.ts +++ b/src/utils/usageIndex.ts @@ -1,6 +1,8 @@ import type { UsageDetail } from '@/utils/usage'; +import { normalizeAuthIndex } from '@/utils/usage'; export type UsageDetailsBySource = Map; +export type UsageDetailsByAuthIndex = Map; const EMPTY_USAGE_DETAILS: UsageDetail[] = []; @@ -22,15 +24,47 @@ export function indexUsageDetailsBySource(usageDetails: UsageDetail[]): UsageDet return map; } +export function indexUsageDetailsByAuthIndex(usageDetails: UsageDetail[]): UsageDetailsByAuthIndex { + const map: UsageDetailsByAuthIndex = new Map(); + + usageDetails.forEach((detail) => { + const authIndexKey = normalizeAuthIndex(detail.auth_index); + if (!authIndexKey) return; + + const bucket = map.get(authIndexKey); + if (bucket) { + bucket.push(detail); + } else { + map.set(authIndexKey, [detail]); + } + }); + + return map; +} + export function collectUsageDetailsForCandidates( usageDetailsBySource: UsageDetailsBySource, candidates: Iterable +): UsageDetail[] { + return collectUsageDetailsForKeys(usageDetailsBySource, candidates); +} + +export function collectUsageDetailsForAuthIndices( + usageDetailsByAuthIndex: UsageDetailsByAuthIndex, + authIndices: Iterable +): UsageDetail[] { + return collectUsageDetailsForKeys(usageDetailsByAuthIndex, authIndices); +} + +function collectUsageDetailsForKeys( + usageDetailsByKey: Map, + keys: Iterable ): UsageDetail[] { let firstDetails: UsageDetail[] | null = null; let merged: UsageDetail[] | null = null; - for (const candidate of candidates) { - const details = usageDetailsBySource.get(candidate); + for (const key of keys) { + const details = usageDetailsByKey.get(key); if (!details || details.length === 0) continue; if (!firstDetails) { From a28920de945ac13611eb88315cf5aff895bb8c78 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Sat, 18 Apr 2026 23:34:40 +0800 Subject: [PATCH 8/8] feat: enhance source information handling and disambiguation in usage components --- src/components/usage/CredentialStatsCard.tsx | 354 ++++++------------ .../usage/RequestEventsDetailsCard.tsx | 67 +++- src/types/sourceInfo.ts | 1 + src/utils/sourceResolver.ts | 132 +++++-- 4 files changed, 271 insertions(+), 283 deletions(-) diff --git a/src/components/usage/CredentialStatsCard.tsx b/src/components/usage/CredentialStatsCard.tsx index 268dc27..46f628c 100644 --- a/src/components/usage/CredentialStatsCard.tsx +++ b/src/components/usage/CredentialStatsCard.tsx @@ -1,16 +1,12 @@ -import { useMemo, useState, useEffect } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Card } from '@/components/ui/Card'; -import { - collectUsageDetails, - buildCandidateUsageSourceIds, - formatCompactNumber, - normalizeAuthIndex -} from '@/utils/usage'; import { authFilesApi } from '@/services/api/authFiles'; -import type { GeminiKeyConfig, ProviderKeyConfig, OpenAIProviderConfig } from '@/types'; +import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types'; import type { AuthFileItem } from '@/types/authFile'; import type { CredentialInfo } from '@/types/sourceInfo'; +import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver'; +import { collectUsageDetails, formatCompactNumber, normalizeAuthIndex } from '@/utils/usage'; import type { UsagePayload } from './hooks/useUsageData'; import styles from '@/pages/UsagePage.module.scss'; @@ -34,11 +30,6 @@ interface CredentialRow { successRate: number; } -interface CredentialBucket { - success: number; - failure: number; -} - export function CredentialStatsCard({ usage, loading, @@ -51,223 +42,86 @@ export function CredentialStatsCard({ const { t } = useTranslation(); const [authFileMap, setAuthFileMap] = useState>(new Map()); - // Fetch auth files for auth_index-based matching useEffect(() => { let cancelled = false; + authFilesApi .list() .then((res) => { if (cancelled) return; + const files = Array.isArray(res) ? res : (res as { files?: AuthFileItem[] })?.files; if (!Array.isArray(files)) return; + const map = new Map(); files.forEach((file) => { - const rawAuthIndex = file['auth_index'] ?? file.authIndex; - const key = normalizeAuthIndex(rawAuthIndex); - if (key) { - map.set(key, { - name: file.name || key, - type: (file.type || file.provider || '').toString(), - }); - } + const key = normalizeAuthIndex(file['auth_index'] ?? file.authIndex); + if (!key) return; + + map.set(key, { + name: file.name || key, + type: (file.type || file.provider || '').toString(), + }); }); setAuthFileMap(map); }) .catch(() => {}); - return () => { cancelled = true; }; + + return () => { + cancelled = true; + }; }, []); - // Aggregate rows: all from bySource only (no separate byAuthIndex rows to avoid duplicates). - // Auth files are used purely for name resolution of unmatched source IDs. + const sourceInfoMap = useMemo( + () => + buildSourceInfoMap({ + geminiApiKeys: geminiKeys, + claudeApiKeys: claudeConfigs, + codexApiKeys: codexConfigs, + vertexApiKeys: vertexConfigs, + openaiCompatibility: openaiProviders, + }), + [claudeConfigs, codexConfigs, geminiKeys, openaiProviders, vertexConfigs] + ); + const rows = useMemo((): CredentialRow[] => { if (!usage) return []; - const details = collectUsageDetails(usage); - const bySource: Record = {}; - const result: CredentialRow[] = []; - const consumedSourceIds = new Set(); - const authIndexToRowIndex = new Map(); - const sourceToAuthIndex = new Map(); - const sourceToAuthFile = new Map(); - const fallbackByAuthIndex = new Map(); - details.forEach((detail) => { - const authIdx = normalizeAuthIndex(detail.auth_index); - const source = detail.source; - const isFailed = detail.failed === true; + const rowMap = new Map(); - if (!source) { - if (!authIdx) return; - const fallback = fallbackByAuthIndex.get(authIdx) ?? { success: 0, failure: 0 }; - if (isFailed) { - fallback.failure += 1; - } else { - fallback.success += 1; - } - fallbackByAuthIndex.set(authIdx, fallback); - return; - } + collectUsageDetails(usage).forEach((detail) => { + const sourceInfo = resolveSourceDisplay( + detail.source ?? '', + detail.auth_index, + sourceInfoMap, + authFileMap + ); + const key = sourceInfo.identityKey ?? sourceInfo.displayName; + const row = + rowMap.get(key) ?? + ({ + key, + displayName: sourceInfo.displayName, + type: sourceInfo.type, + success: 0, + failure: 0, + total: 0, + successRate: 100, + } satisfies CredentialRow); - const bucket = bySource[source] ?? { success: 0, failure: 0 }; - if (isFailed) { - bucket.failure += 1; + if (detail.failed === true) { + row.failure += 1; } else { - bucket.success += 1; + row.success += 1; } - bySource[source] = bucket; - if (authIdx && !sourceToAuthIndex.has(source)) { - sourceToAuthIndex.set(source, authIdx); - } - if (authIdx && !sourceToAuthFile.has(source)) { - const mapped = authFileMap.get(authIdx); - if (mapped) sourceToAuthFile.set(source, mapped); - } + row.total = row.success + row.failure; + row.successRate = row.total > 0 ? (row.success / row.total) * 100 : 100; + rowMap.set(key, row); }); - const mergeBucketToRow = (index: number, bucket: CredentialBucket) => { - const target = result[index]; - if (!target) return; - target.success += bucket.success; - target.failure += bucket.failure; - target.total = target.success + target.failure; - target.successRate = target.total > 0 ? (target.success / target.total) * 100 : 100; - }; - - // Aggregate all candidate source IDs for one provider config into a single row - const addConfigRow = ( - apiKey: string, - prefix: string | undefined, - name: string, - type: string, - rowKey: string, - ) => { - const candidates = buildCandidateUsageSourceIds({ apiKey, prefix }); - let success = 0; - let failure = 0; - candidates.forEach((id) => { - const bucket = bySource[id]; - if (bucket) { - success += bucket.success; - failure += bucket.failure; - consumedSourceIds.add(id); - } - }); - const total = success + failure; - if (total > 0) { - result.push({ - key: rowKey, - displayName: name, - type, - success, - failure, - total, - successRate: (success / total) * 100, - }); - } - }; - - // Provider rows — one row per config, stats merged across all its candidate source IDs - geminiKeys.forEach((c, i) => - addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Gemini #${i + 1}`, 'gemini', `gemini:${i}`)); - claudeConfigs.forEach((c, i) => - addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Claude #${i + 1}`, 'claude', `claude:${i}`)); - codexConfigs.forEach((c, i) => - addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Codex #${i + 1}`, 'codex', `codex:${i}`)); - vertexConfigs.forEach((c, i) => - addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Vertex #${i + 1}`, 'vertex', `vertex:${i}`)); - // OpenAI compatibility providers — one row per provider, merged across all apiKey entries (prefix counted once). - openaiProviders.forEach((provider, providerIndex) => { - const prefix = provider.prefix; - const displayName = prefix?.trim() || provider.name || `OpenAI #${providerIndex + 1}`; - - const candidates = new Set(); - buildCandidateUsageSourceIds({ prefix }).forEach((id) => candidates.add(id)); - (provider.apiKeyEntries || []).forEach((entry) => { - buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => candidates.add(id)); - }); - - let success = 0; - let failure = 0; - candidates.forEach((id) => { - const bucket = bySource[id]; - if (bucket) { - success += bucket.success; - failure += bucket.failure; - consumedSourceIds.add(id); - } - }); - - const total = success + failure; - if (total > 0) { - result.push({ - key: `openai:${providerIndex}`, - displayName, - type: 'openai', - success, - failure, - total, - successRate: (success / total) * 100, - }); - } - }); - - // Remaining unmatched bySource entries — resolve name from auth files if possible - Object.entries(bySource).forEach(([key, bucket]) => { - if (consumedSourceIds.has(key)) return; - const total = bucket.success + bucket.failure; - const authFile = sourceToAuthFile.get(key); - const row = { - key, - displayName: authFile?.name || (key.startsWith('t:') ? key.slice(2) : key), - type: authFile?.type || '', - success: bucket.success, - failure: bucket.failure, - total, - successRate: total > 0 ? (bucket.success / total) * 100 : 100, - }; - const rowIndex = result.push(row) - 1; - const authIdx = sourceToAuthIndex.get(key); - if (authIdx && !authIndexToRowIndex.has(authIdx)) { - authIndexToRowIndex.set(authIdx, rowIndex); - } - }); - - // Include requests that have auth_index but missing source. - fallbackByAuthIndex.forEach((bucket, authIdx) => { - if (bucket.success + bucket.failure === 0) return; - - const mapped = authFileMap.get(authIdx); - let targetRowIndex = authIndexToRowIndex.get(authIdx); - if (targetRowIndex === undefined && mapped) { - const matchedIndex = result.findIndex( - (row) => row.displayName === mapped.name && row.type === mapped.type - ); - if (matchedIndex >= 0) { - targetRowIndex = matchedIndex; - authIndexToRowIndex.set(authIdx, matchedIndex); - } - } - - if (targetRowIndex !== undefined) { - mergeBucketToRow(targetRowIndex, bucket); - return; - } - - const total = bucket.success + bucket.failure; - const rowIndex = result.push({ - key: `auth:${authIdx}`, - displayName: mapped?.name || authIdx, - type: mapped?.type || '', - success: bucket.success, - failure: bucket.failure, - total, - successRate: (bucket.success / total) * 100 - }) - 1; - authIndexToRowIndex.set(authIdx, rowIndex); - }); - - return result.sort((a, b) => b.total - a.total); - }, [usage, geminiKeys, claudeConfigs, codexConfigs, vertexConfigs, openaiProviders, authFileMap]); + return Array.from(rowMap.values()).sort((a, b) => b.total - a.total); + }, [authFileMap, sourceInfoMap, usage]); return ( @@ -275,51 +129,55 @@ export function CredentialStatsCard({
{t('common.loading')}
) : rows.length > 0 ? (
-
- - - - - - - - - - {rows.map((row) => ( - - - - +
+
{t('usage_stats.credential_name')}{t('usage_stats.requests_count')}{t('usage_stats.success_rate')}
- {row.displayName} - {row.type && ( - {row.type} - )} - - - {formatCompactNumber(row.total)} - - ({row.success.toLocaleString()}{' '} - {row.failure.toLocaleString()}) - - - - = 95 - ? styles.statSuccess - : row.successRate >= 80 - ? styles.statNeutral - : styles.statFailure - } - > - {row.successRate.toFixed(1)}% - -
+ + + + + - ))} - -
{t('usage_stats.credential_name')}{t('usage_stats.requests_count')}{t('usage_stats.success_rate')}
-
+ + + {rows.map((row) => ( + + + {row.displayName} + {row.type && {row.type}} + + + + {formatCompactNumber(row.total)} + + ( + + {row.success.toLocaleString()} + {' '} + + {row.failure.toLocaleString()} + + ) + + + + + = 95 + ? styles.statSuccess + : row.successRate >= 80 + ? styles.statNeutral + : styles.statFailure + } + > + {row.successRate.toFixed(1)}% + + + + ))} + + +
) : (
{t('usage_stats.no_data')}
diff --git a/src/components/usage/RequestEventsDetailsCard.tsx b/src/components/usage/RequestEventsDetailsCard.tsx index 2a69b22..b19404e 100644 --- a/src/components/usage/RequestEventsDetailsCard.tsx +++ b/src/components/usage/RequestEventsDetailsCard.tsx @@ -30,6 +30,7 @@ type RequestEventRow = { timestampMs: number; timestampLabel: string; model: string; + sourceKey: string; sourceRaw: string; source: string; sourceType: string; @@ -126,7 +127,7 @@ export function RequestEventsDetailsCard({ const rows = useMemo(() => { const details = collectUsageDetails(usage); - return details + const baseRows = details .map((detail, index) => { const timestamp = detail.timestamp; const timestampMs = @@ -147,6 +148,7 @@ export function RequestEventsDetailsCard({ authFileMap ); const source = sourceInfo.displayName; + const sourceKey = sourceInfo.identityKey ?? `source:${sourceRaw || source}`; const sourceType = sourceInfo.type; const model = String(detail.__modelName ?? '').trim() || '-'; const inputTokens = Math.max(toNumber(detail.tokens?.input_tokens), 0); @@ -163,11 +165,12 @@ export function RequestEventsDetailsCard({ const latencyMs = extractLatencyMs(detail); return { - id: `${timestamp}-${model}-${sourceRaw || source}-${authIndex}-${index}`, + id: `${timestamp}-${model}-${sourceKey}-${authIndex}-${index}`, timestamp, timestampMs: Number.isNaN(timestampMs) ? 0 : timestampMs, timestampLabel: date ? date.toLocaleString(i18n.language) : timestamp || '-', model, + sourceKey, sourceRaw: sourceRaw || '-', source, sourceType, @@ -180,7 +183,41 @@ export function RequestEventsDetailsCard({ cachedTokens, totalTokens, }; - }) + }); + + const sourceLabelKeyMap = new Map>(); + baseRows.forEach((row) => { + const keys = sourceLabelKeyMap.get(row.source) ?? new Set(); + keys.add(row.sourceKey); + sourceLabelKeyMap.set(row.source, keys); + }); + + const buildDisambiguatedSourceLabel = (row: RequestEventRow) => { + const labelKeyCount = sourceLabelKeyMap.get(row.source)?.size ?? 0; + if (labelKeyCount <= 1) { + return row.source; + } + + if (row.authIndex !== '-') { + return `${row.source} · ${row.authIndex}`; + } + + if (row.sourceRaw !== '-' && row.sourceRaw !== row.source) { + return `${row.source} · ${row.sourceRaw}`; + } + + if (row.sourceType) { + return `${row.source} · ${row.sourceType}`; + } + + return `${row.source} · ${row.sourceKey}`; + }; + + return baseRows + .map((row) => ({ + ...row, + source: buildDisambiguatedSourceLabel(row), + })) .sort((a, b) => b.timestampMs - a.timestampMs); }, [authFileMap, i18n.language, sourceInfoMap, usage]); @@ -197,16 +234,22 @@ export function RequestEventsDetailsCard({ [rows, t] ); - const sourceOptions = useMemo( - () => [ + const sourceOptions = useMemo(() => { + const optionMap = new Map(); + rows.forEach((row) => { + if (!optionMap.has(row.sourceKey)) { + optionMap.set(row.sourceKey, row.source); + } + }); + + return [ { value: ALL_FILTER, label: t('usage_stats.filter_all') }, - ...Array.from(new Set(rows.map((row) => row.source))).map((source) => ({ - value: source, - label: source, + ...Array.from(optionMap.entries()).map(([value, label]) => ({ + value, + label, })), - ], - [rows, t] - ); + ]; + }, [rows, t]); const authIndexOptions = useMemo( () => [ @@ -244,7 +287,7 @@ export function RequestEventsDetailsCard({ const modelMatched = effectiveModelFilter === ALL_FILTER || row.model === effectiveModelFilter; const sourceMatched = - effectiveSourceFilter === ALL_FILTER || row.source === effectiveSourceFilter; + effectiveSourceFilter === ALL_FILTER || row.sourceKey === effectiveSourceFilter; const authIndexMatched = effectiveAuthIndexFilter === ALL_FILTER || row.authIndex === effectiveAuthIndexFilter; return modelMatched && sourceMatched && authIndexMatched; diff --git a/src/types/sourceInfo.ts b/src/types/sourceInfo.ts index 205e318..bd7d725 100644 --- a/src/types/sourceInfo.ts +++ b/src/types/sourceInfo.ts @@ -1,6 +1,7 @@ export type SourceInfo = { displayName: string; type: string; + identityKey?: string; }; export type CredentialInfo = { diff --git a/src/utils/sourceResolver.ts b/src/utils/sourceResolver.ts index 040b70c..8b0fd76 100644 --- a/src/utils/sourceResolver.ts +++ b/src/utils/sourceResolver.ts @@ -10,20 +10,64 @@ export interface SourceInfoMapInput { openaiCompatibility?: OpenAIProviderConfig[]; } -export function buildSourceInfoMap(input: SourceInfoMapInput): Map { - const map = new Map(); +type SourceInfoEntry = Required>; - const registerSource = (sourceId: string, displayName: string, type: string) => { - if (!sourceId || !displayName || map.has(sourceId)) return; - map.set(sourceId, { displayName, type }); - }; +export interface SourceInfoMap { + byAuthIndex: Map; + bySource: Map; +} - const registerCandidates = (displayName: string, type: string, candidates: string[]) => { - candidates.forEach((sourceId) => registerSource(sourceId, displayName, type)); +const buildProviderIdentityKey = (type: string, index: number) => `${type}:${index}`; + +const registerIdentity = ( + map: Map, + key: string | null | undefined, + entry: SourceInfoEntry +) => { + if (!key) return; + + const existing = map.get(key); + if (existing === undefined) { + map.set(key, entry); + return; + } + + if (existing === null) { + return; + } + + if (existing.identityKey === entry.identityKey) { + return; + } + + map.set(key, null); +}; + +const formatRawSourceDisplayName = (source: string) => { + if (!source) return '-'; + return source.startsWith('t:') ? source.slice(2) : source; +}; + +export function buildSourceInfoMap(input: SourceInfoMapInput): SourceInfoMap { + const byAuthIndex = new Map(); + const bySource = new Map(); + + const registerProvider = ( + entry: SourceInfoEntry, + authIndices: Array, + candidates: Iterable + ) => { + authIndices.forEach((authIndex) => { + registerIdentity(byAuthIndex, normalizeAuthIndex(authIndex), entry); + }); + + Array.from(candidates).forEach((candidate) => { + registerIdentity(bySource, candidate, entry); + }); }; const providers: Array<{ - items: Array<{ apiKey?: string; prefix?: string }>; + items: Array<{ apiKey?: string; prefix?: string; authIndex?: string }>; type: string; label: string; }> = [ @@ -35,49 +79,91 @@ export function buildSourceInfoMap(input: SourceInfoMapInput): Map { items.forEach((item, index) => { - const displayName = item.prefix?.trim() || `${label} #${index + 1}`; - registerCandidates( - displayName, - type, + registerProvider( + { + displayName: item.prefix?.trim() || `${label} #${index + 1}`, + type, + identityKey: buildProviderIdentityKey(type, index), + }, + [item.authIndex], buildCandidateUsageSourceIds({ apiKey: item.apiKey, prefix: item.prefix }) ); }); }); - // OpenAI 特殊处理:多 apiKeyEntries (input.openaiCompatibility || []).forEach((provider, providerIndex) => { - const displayName = provider.prefix?.trim() || provider.name || `OpenAI #${providerIndex + 1}`; const candidates = new Set(); + const authIndices: Array = [provider.authIndex]; + buildCandidateUsageSourceIds({ prefix: provider.prefix }).forEach((id) => candidates.add(id)); (provider.apiKeyEntries || []).forEach((entry) => { + authIndices.push(entry.authIndex); buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => candidates.add(id)); }); - registerCandidates(displayName, 'openai', Array.from(candidates)); + + registerProvider( + { + displayName: provider.prefix?.trim() || provider.name || `OpenAI #${providerIndex + 1}`, + type: 'openai', + identityKey: buildProviderIdentityKey('openai', providerIndex), + }, + authIndices, + candidates + ); }); - return map; + return { byAuthIndex, bySource }; } export function resolveSourceDisplay( sourceRaw: string, authIndex: unknown, - sourceInfoMap: Map, + sourceInfoMap: SourceInfoMap, authFileMap: Map ): SourceInfo { const source = sourceRaw.trim(); - const matched = sourceInfoMap.get(source); - if (matched) return matched; - const authIndexKey = normalizeAuthIndex(authIndex); + if (authIndexKey) { + const matchedByAuthIndex = sourceInfoMap.byAuthIndex.get(authIndexKey); + if (matchedByAuthIndex) { + return matchedByAuthIndex; + } + const authInfo = authFileMap.get(authIndexKey); if (authInfo) { - return { displayName: authInfo.name || authIndexKey, type: authInfo.type }; + return { + displayName: authInfo.name || authIndexKey, + type: authInfo.type, + identityKey: `auth:${authIndexKey}`, + }; } } + const matchedBySource = source ? sourceInfoMap.bySource.get(source) : null; + if (matchedBySource) { + return matchedBySource; + } + + if (source) { + return { + displayName: formatRawSourceDisplayName(source), + type: '', + identityKey: `source:${source}`, + }; + } + + if (authIndexKey) { + return { + displayName: authIndexKey, + type: '', + identityKey: `auth:${authIndexKey}`, + }; + } + return { - displayName: source.startsWith('t:') ? source.slice(2) : source || '-', + displayName: '-', type: '', + identityKey: 'source:-', }; }