diff --git a/go.mod b/go.mod index c2e4383d..86ed92f2 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.24.2 require ( github.com/andybalholm/brotli v1.0.6 + github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 @@ -34,7 +35,6 @@ require ( cloud.google.com/go/compute/metadata v0.3.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect - github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect diff --git a/internal/tui/app.go b/internal/tui/app.go index c6c21c2b..d28a84f3 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -20,8 +20,6 @@ const ( tabLogs ) -var tabNames = []string{"Dashboard", "Config", "Auth Files", "API Keys", "OAuth", "Usage", "Logs"} - // App is the root bubbletea model that contains all tab sub-models. type App struct { activeTab int @@ -50,7 +48,7 @@ func NewApp(port int, secretKey string, hook *LogHook) App { client := NewClient(port, secretKey) return App{ activeTab: tabDashboard, - tabs: tabNames, + tabs: TabNames(), dashboard: newDashboardModel(client), config: newConfigTabModel(client), auth: newAuthTabModel(client), @@ -102,13 +100,50 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if a.activeTab != tabLogs { return a, tea.Quit } + case "L": + ToggleLocale() + a.tabs = TabNames() + // Broadcast locale change to ALL tabs so each re-renders + var cmds []tea.Cmd + var cmd tea.Cmd + a.dashboard, cmd = a.dashboard.Update(localeChangedMsg{}) + if cmd != nil { + cmds = append(cmds, cmd) + } + a.config, cmd = a.config.Update(localeChangedMsg{}) + if cmd != nil { + cmds = append(cmds, cmd) + } + a.auth, cmd = a.auth.Update(localeChangedMsg{}) + if cmd != nil { + cmds = append(cmds, cmd) + } + a.keys, cmd = a.keys.Update(localeChangedMsg{}) + if cmd != nil { + cmds = append(cmds, cmd) + } + a.oauth, cmd = a.oauth.Update(localeChangedMsg{}) + if cmd != nil { + cmds = append(cmds, cmd) + } + a.usage, cmd = a.usage.Update(localeChangedMsg{}) + if cmd != nil { + cmds = append(cmds, cmd) + } + a.logs, cmd = a.logs.Update(localeChangedMsg{}) + if cmd != nil { + cmds = append(cmds, cmd) + } + return a, tea.Batch(cmds...) case "tab": prevTab := a.activeTab a.activeTab = (a.activeTab + 1) % len(a.tabs) + a.tabs = TabNames() return a, a.initTabIfNeeded(prevTab) case "shift+tab": prevTab := a.activeTab a.activeTab = (a.activeTab - 1 + len(a.tabs)) % len(a.tabs) + a.tabs = TabNames() return a, a.initTabIfNeeded(prevTab) } } @@ -145,6 +180,9 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, cmd } +// localeChangedMsg is broadcast to all tabs when the user toggles locale. +type localeChangedMsg struct{} + func (a *App) initTabIfNeeded(_ int) tea.Cmd { if a.initialized[a.activeTab] { return nil @@ -171,7 +209,7 @@ func (a *App) initTabIfNeeded(_ int) tea.Cmd { func (a App) View() string { if !a.ready { - return "Initializing TUI..." + return T("initializing_tui") } var sb strings.Builder @@ -219,8 +257,8 @@ func (a App) renderTabBar() string { } func (a App) renderStatusBar() string { - left := " CLIProxyAPI Management TUI" - right := "Tab/Shift+Tab: switch • q/Ctrl+C: quit " + left := T("status_left") + right := T("status_right") gap := a.width - lipgloss.Width(left) - lipgloss.Width(right) if gap < 0 { gap = 0 diff --git a/internal/tui/auth_tab.go b/internal/tui/auth_tab.go index c6a38ae7..88f9a246 100644 --- a/internal/tui/auth_tab.go +++ b/internal/tui/auth_tab.go @@ -76,6 +76,9 @@ func (m authTabModel) fetchFiles() tea.Msg { func (m authTabModel) Update(msg tea.Msg) (authTabModel, tea.Cmd) { switch msg := msg.(type) { + case localeChangedMsg: + m.viewport.SetContent(m.renderContent()) + return m, nil case authFilesMsg: if msg.err != nil { m.err = msg.err @@ -122,7 +125,7 @@ func (m authTabModel) Update(msg tea.Msg) (authTabModel, tea.Cmd) { if err != nil { return authActionMsg{err: err} } - return authActionMsg{action: fmt.Sprintf("Updated %s on %s", fieldKey, fileName)} + return authActionMsg{action: fmt.Sprintf(T("updated_field"), fieldKey, fileName)} } case "esc": m.editing = false @@ -150,7 +153,7 @@ func (m authTabModel) Update(msg tea.Msg) (authTabModel, tea.Cmd) { if err != nil { return authActionMsg{err: err} } - return authActionMsg{action: fmt.Sprintf("Deleted %s", name)} + return authActionMsg{action: fmt.Sprintf(T("deleted"), name)} } } m.viewport.SetContent(m.renderContent()) @@ -202,9 +205,9 @@ func (m authTabModel) Update(msg tea.Msg) (authTabModel, tea.Cmd) { if err != nil { return authActionMsg{err: err} } - action := "Enabled" + action := T("enabled") if newDisabled { - action = "Disabled" + action = T("disabled") } return authActionMsg{action: fmt.Sprintf("%s %s", action, name)} } @@ -267,7 +270,7 @@ func (m *authTabModel) SetSize(w, h int) { func (m authTabModel) View() string { if !m.ready { - return "Loading..." + return T("loading") } return m.viewport.View() } @@ -275,11 +278,11 @@ func (m authTabModel) View() string { func (m authTabModel) renderContent() string { var sb strings.Builder - sb.WriteString(titleStyle.Render("🔑 Auth Files")) + sb.WriteString(titleStyle.Render(T("auth_title"))) sb.WriteString("\n") - sb.WriteString(helpStyle.Render(" [↑↓/jk] navigate • [Enter] expand • [e] enable/disable • [d] delete • [r] refresh")) + sb.WriteString(helpStyle.Render(T("auth_help1"))) sb.WriteString("\n") - sb.WriteString(helpStyle.Render(" [1] edit prefix • [2] edit proxy_url • [3] edit priority")) + sb.WriteString(helpStyle.Render(T("auth_help2"))) sb.WriteString("\n") sb.WriteString(strings.Repeat("─", m.width)) sb.WriteString("\n") @@ -291,7 +294,7 @@ func (m authTabModel) renderContent() string { } if len(m.files) == 0 { - sb.WriteString(subtitleStyle.Render("\n No auth files found")) + sb.WriteString(subtitleStyle.Render(T("no_auth_files"))) sb.WriteString("\n") return sb.String() } @@ -303,10 +306,10 @@ func (m authTabModel) renderContent() string { disabled := getBool(f, "disabled") statusIcon := successStyle.Render("●") - statusText := "active" + statusText := T("status_active") if disabled { statusIcon = lipgloss.NewStyle().Foreground(colorMuted).Render("○") - statusText = "disabled" + statusText = T("status_disabled") } cursor := " " @@ -332,7 +335,7 @@ func (m authTabModel) renderContent() string { // Delete confirmation if m.confirm == i { - sb.WriteString(warningStyle.Render(fmt.Sprintf(" ⚠ Delete %s? [y/n] ", name))) + sb.WriteString(warningStyle.Render(fmt.Sprintf(" "+T("confirm_delete"), name))) sb.WriteString("\n") } @@ -340,7 +343,7 @@ func (m authTabModel) renderContent() string { if m.editing && i == m.cursor { sb.WriteString(m.editInput.View()) sb.WriteString("\n") - sb.WriteString(helpStyle.Render(" Enter: save • Esc: cancel")) + sb.WriteString(helpStyle.Render(" " + T("enter_save") + " • " + T("esc_cancel"))) sb.WriteString("\n") } @@ -398,7 +401,7 @@ func (m authTabModel) renderDetail(f map[string]any) string { val := getAnyString(f, field.key) if val == "" || val == "" { if field.editable { - val = "(not set)" + val = T("not_set") } else { continue } diff --git a/internal/tui/client.go b/internal/tui/client.go index b2e15e68..81016cc5 100644 --- a/internal/tui/client.go +++ b/internal/tui/client.go @@ -206,6 +206,34 @@ func (c *Client) GetAPIKeys() ([]string, error) { return result, nil } +// AddAPIKey adds a new API key by sending old=nil, new=key which appends. +func (c *Client) AddAPIKey(key string) error { + body := map[string]any{"old": nil, "new": key} + jsonBody, _ := json.Marshal(body) + _, err := c.patch("/v0/management/api-keys", strings.NewReader(string(jsonBody))) + return err +} + +// EditAPIKey replaces an API key at the given index. +func (c *Client) EditAPIKey(index int, newValue string) error { + body := map[string]any{"index": index, "value": newValue} + jsonBody, _ := json.Marshal(body) + _, err := c.patch("/v0/management/api-keys", strings.NewReader(string(jsonBody))) + return err +} + +// DeleteAPIKey deletes an API key by index. +func (c *Client) DeleteAPIKey(index int) error { + _, code, err := c.doRequest("DELETE", fmt.Sprintf("/v0/management/api-keys?index=%d", index), nil) + if err != nil { + return err + } + if code >= 400 { + return fmt.Errorf("delete failed (HTTP %d)", code) + } + return nil +} + // GetGeminiKeys fetches Gemini API keys. // API returns {"gemini-api-key": [...]}. func (c *Client) GetGeminiKeys() ([]map[string]any, error) { diff --git a/internal/tui/config_tab.go b/internal/tui/config_tab.go index 39f3ce68..762c3ac2 100644 --- a/internal/tui/config_tab.go +++ b/internal/tui/config_tab.go @@ -64,6 +64,9 @@ func (m configTabModel) fetchConfig() tea.Msg { func (m configTabModel) Update(msg tea.Msg) (configTabModel, tea.Cmd) { switch msg := msg.(type) { + case localeChangedMsg: + m.viewport.SetContent(m.renderContent()) + return m, nil case configDataMsg: if msg.err != nil { m.err = msg.err @@ -79,7 +82,7 @@ func (m configTabModel) Update(msg tea.Msg) (configTabModel, tea.Cmd) { if msg.err != nil { m.message = errorStyle.Render("✗ " + msg.err.Error()) } else { - m.message = successStyle.Render("✓ Updated successfully") + m.message = successStyle.Render(T("updated_ok")) } m.viewport.SetContent(m.renderContent()) // Refresh config from server @@ -178,7 +181,7 @@ func (m configTabModel) submitEdit(idx int, newValue string) tea.Cmd { case "int": v, parseErr := strconv.Atoi(newValue) if parseErr != nil { - return configUpdateMsg{err: fmt.Errorf("invalid integer: %s", newValue)} + return configUpdateMsg{err: fmt.Errorf("%s: %s", T("invalid_int"), newValue)} } err = m.client.PutIntField(f.apiPath, v) case "string": @@ -214,7 +217,7 @@ func (m *configTabModel) ensureCursorVisible() { func (m configTabModel) View() string { if !m.ready { - return "Loading..." + return T("loading") } return m.viewport.View() } @@ -222,7 +225,7 @@ func (m configTabModel) View() string { func (m configTabModel) renderContent() string { var sb strings.Builder - sb.WriteString(titleStyle.Render("⚙ Configuration")) + sb.WriteString(titleStyle.Render(T("config_title"))) sb.WriteString("\n") if m.message != "" { @@ -230,9 +233,9 @@ func (m configTabModel) renderContent() string { sb.WriteString("\n") } - sb.WriteString(helpStyle.Render(" [↑↓/jk] navigate • [Enter/Space] edit • [r] refresh")) + sb.WriteString(helpStyle.Render(T("config_help1"))) sb.WriteString("\n") - sb.WriteString(helpStyle.Render(" Bool fields: Enter to toggle • String/Int: Enter to type, Enter to confirm, Esc to cancel")) + sb.WriteString(helpStyle.Render(T("config_help2"))) sb.WriteString("\n\n") if m.err != nil { @@ -241,7 +244,7 @@ func (m configTabModel) renderContent() string { } if len(m.fields) == 0 { - sb.WriteString(subtitleStyle.Render(" No configuration loaded")) + sb.WriteString(subtitleStyle.Render(T("no_config"))) return sb.String() } @@ -341,23 +344,23 @@ func (m configTabModel) parseConfig(cfg map[string]any) []configField { func fieldSection(apiPath string) string { if strings.HasPrefix(apiPath, "ampcode/") { - return "AMP Code" + return T("section_ampcode") } if strings.HasPrefix(apiPath, "quota-exceeded/") { - return "Quota Exceeded Handling" + return T("section_quota") } if strings.HasPrefix(apiPath, "routing/") { - return "Routing" + return T("section_routing") } switch apiPath { case "port", "host", "debug", "proxy-url", "request-retry", "max-retry-interval", "force-model-prefix": - return "Server" + return T("section_server") case "logging-to-file", "logs-max-total-size-mb", "error-logs-max-files", "usage-statistics-enabled", "request-log": - return "Logging & Stats" + return T("section_logging") case "ws-auth": - return "WebSocket" + return T("section_websocket") default: - return "Other" + return T("section_other") } } @@ -378,7 +381,7 @@ func getBoolNested(m map[string]any, keys ...string) bool { func maskIfNotEmpty(s string) string { if s == "" { - return "(not set)" + return T("not_set") } return maskKey(s) } diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go index 02033830..e4215dc6 100644 --- a/internal/tui/dashboard.go +++ b/internal/tui/dashboard.go @@ -57,6 +57,9 @@ func (m dashboardModel) fetchData() tea.Msg { func (m dashboardModel) Update(msg tea.Msg) (dashboardModel, tea.Cmd) { switch msg := msg.(type) { + case localeChangedMsg: + // Re-fetch data to re-render with new locale + return m, m.fetchData case dashboardDataMsg: if msg.err != nil { m.err = msg.err @@ -97,7 +100,7 @@ func (m *dashboardModel) SetSize(w, h int) { func (m dashboardModel) View() string { if !m.ready { - return "Loading..." + return T("loading") } return m.viewport.View() } @@ -105,19 +108,15 @@ func (m dashboardModel) View() string { func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []map[string]any, apiKeys []string) string { var sb strings.Builder - sb.WriteString(titleStyle.Render("📊 Dashboard")) + sb.WriteString(titleStyle.Render(T("dashboard_title"))) sb.WriteString("\n") - sb.WriteString(helpStyle.Render(" [r] refresh • [↑↓] scroll")) + sb.WriteString(helpStyle.Render(T("dashboard_help"))) sb.WriteString("\n\n") // ━━━ Connection Status ━━━ - port := 0.0 - if cfg != nil { - port = getFloat(cfg, "port") - } connStyle := lipgloss.NewStyle().Bold(true).Foreground(colorSuccess) - sb.WriteString(connStyle.Render("● 已连接")) - sb.WriteString(fmt.Sprintf(" http://127.0.0.1:%.0f", port)) + sb.WriteString(connStyle.Render(T("connected"))) + sb.WriteString(fmt.Sprintf(" %s", m.client.baseURL)) sb.WriteString("\n\n") // ━━━ Stats Cards ━━━ @@ -141,7 +140,7 @@ func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []m card1 := cardStyle.Render(fmt.Sprintf( "%s\n%s", lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("111")).Render(fmt.Sprintf("🔑 %d", keyCount)), - lipgloss.NewStyle().Foreground(colorMuted).Render("管理密钥"), + lipgloss.NewStyle().Foreground(colorMuted).Render(T("mgmt_keys")), )) // Card 2: Auth Files @@ -155,7 +154,7 @@ func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []m card2 := cardStyle.Render(fmt.Sprintf( "%s\n%s", lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("76")).Render(fmt.Sprintf("📄 %d", authCount)), - lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("认证文件 (%d active)", activeAuth)), + lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("%s (%d %s)", T("auth_files_label"), activeAuth, T("active_suffix"))), )) // Card 3: Total Requests @@ -174,7 +173,7 @@ func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []m card3 := cardStyle.Render(fmt.Sprintf( "%s\n%s", lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")).Render(fmt.Sprintf("📈 %d", totalReqs)), - lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("请求 (✓%d ✗%d)", successReqs, failedReqs)), + lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("%s (✓%d ✗%d)", T("total_requests"), successReqs, failedReqs)), )) // Card 4: Total Tokens @@ -182,14 +181,14 @@ func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []m card4 := cardStyle.Render(fmt.Sprintf( "%s\n%s", lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("170")).Render(fmt.Sprintf("🔤 %s", tokenStr)), - lipgloss.NewStyle().Foreground(colorMuted).Render("总 Tokens"), + lipgloss.NewStyle().Foreground(colorMuted).Render(T("total_tokens")), )) sb.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, card1, " ", card2, " ", card3, " ", card4)) sb.WriteString("\n\n") // ━━━ Current Config ━━━ - sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render("当前配置")) + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(T("current_config"))) sb.WriteString("\n") sb.WriteString(strings.Repeat("─", minInt(m.width, 60))) sb.WriteString("\n") @@ -210,16 +209,16 @@ func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []m label string value string }{ - {"启用调试模式", boolEmoji(debug)}, - {"启用使用统计", boolEmoji(usageEnabled)}, - {"启用日志记录到文件", boolEmoji(loggingToFile)}, - {"重试次数", fmt.Sprintf("%.0f", retry)}, + {T("debug_mode"), boolEmoji(debug)}, + {T("usage_stats"), boolEmoji(usageEnabled)}, + {T("log_to_file"), boolEmoji(loggingToFile)}, + {T("retry_count"), fmt.Sprintf("%.0f", retry)}, } if proxyURL != "" { configItems = append(configItems, struct { label string value string - }{"代理 URL", proxyURL}) + }{T("proxy_url"), proxyURL}) } // Render config items as a compact row @@ -237,7 +236,7 @@ func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []m } } sb.WriteString(fmt.Sprintf(" %s %s\n", - labelStyle.Render("路由策略:"), + labelStyle.Render(T("routing_strategy")+":"), valueStyle.Render(strategy))) } @@ -247,12 +246,12 @@ func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []m if usage != nil { if usageMap, ok := usage["usage"].(map[string]any); ok { if apis, ok := usageMap["apis"].(map[string]any); ok && len(apis) > 0 { - sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render("模型统计")) + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(T("model_stats"))) sb.WriteString("\n") sb.WriteString(strings.Repeat("─", minInt(m.width, 60))) sb.WriteString("\n") - header := fmt.Sprintf(" %-40s %10s %12s", "Model", "Requests", "Tokens") + header := fmt.Sprintf(" %-40s %10s %12s", T("model"), T("requests"), T("tokens")) sb.WriteString(tableHeaderStyle.Render(header)) sb.WriteString("\n") @@ -315,9 +314,9 @@ func getBool(m map[string]any, key string) bool { func boolEmoji(b bool) string { if b { - return "是 ✓" + return T("bool_yes") } - return "否" + return T("bool_no") } func formatLargeNumber(n int64) string { diff --git a/internal/tui/i18n.go b/internal/tui/i18n.go new file mode 100644 index 00000000..1b54a9af --- /dev/null +++ b/internal/tui/i18n.go @@ -0,0 +1,350 @@ +package tui + +// i18n provides a simple internationalization system for the TUI. +// Supported locales: "zh" (Chinese, default), "en" (English). + +var currentLocale = "zh" + +// SetLocale changes the active locale. +func SetLocale(locale string) { + if _, ok := locales[locale]; ok { + currentLocale = locale + } +} + +// CurrentLocale returns the active locale code. +func CurrentLocale() string { + return currentLocale +} + +// ToggleLocale switches between zh and en. +func ToggleLocale() { + if currentLocale == "zh" { + currentLocale = "en" + } else { + currentLocale = "zh" + } +} + +// T returns the translated string for the given key. +func T(key string) string { + if m, ok := locales[currentLocale]; ok { + if v, ok := m[key]; ok { + return v + } + } + // Fallback to English + if m, ok := locales["en"]; ok { + if v, ok := m[key]; ok { + return v + } + } + return key +} + +var locales = map[string]map[string]string{ + "zh": zhStrings, + "en": enStrings, +} + +// ────────────────────────────────────────── +// Tab names +// ────────────────────────────────────────── +var zhTabNames = []string{"仪表盘", "配置", "认证文件", "API 密钥", "OAuth", "使用统计", "日志"} +var enTabNames = []string{"Dashboard", "Config", "Auth Files", "API Keys", "OAuth", "Usage", "Logs"} + +// TabNames returns tab names in the current locale. +func TabNames() []string { + if currentLocale == "zh" { + return zhTabNames + } + return enTabNames +} + +var zhStrings = map[string]string{ + // ── Common ── + "loading": "加载中...", + "refresh": "刷新", + "save": "保存", + "cancel": "取消", + "confirm": "确认", + "yes": "是", + "no": "否", + "error": "错误", + "success": "成功", + "navigate": "导航", + "scroll": "滚动", + "enter_save": "Enter: 保存", + "esc_cancel": "Esc: 取消", + "enter_submit": "Enter: 提交", + "press_r": "[r] 刷新", + "press_scroll": "[↑↓] 滚动", + "not_set": "(未设置)", + "error_prefix": "⚠ 错误: ", + + // ── Status bar ── + "status_left": " CLIProxyAPI 管理终端", + "status_right": "Tab/Shift+Tab: 切换 • L: 语言 • q/Ctrl+C: 退出 ", + "initializing_tui": "正在初始化...", + + // ── Dashboard ── + "dashboard_title": "📊 仪表盘", + "dashboard_help": " [r] 刷新 • [↑↓] 滚动", + "connected": "● 已连接", + "mgmt_keys": "管理密钥", + "auth_files_label": "认证文件", + "active_suffix": "活跃", + "total_requests": "请求", + "success_label": "成功", + "failure_label": "失败", + "total_tokens": "总 Tokens", + "current_config": "当前配置", + "debug_mode": "启用调试模式", + "usage_stats": "启用使用统计", + "log_to_file": "启用日志记录到文件", + "retry_count": "重试次数", + "proxy_url": "代理 URL", + "routing_strategy": "路由策略", + "model_stats": "模型统计", + "model": "模型", + "requests": "请求数", + "tokens": "Tokens", + "bool_yes": "是 ✓", + "bool_no": "否", + + // ── Config ── + "config_title": "⚙ 配置", + "config_help1": " [↑↓/jk] 导航 • [Enter/Space] 编辑 • [r] 刷新", + "config_help2": " 布尔: Enter 切换 • 文本/数字: Enter 输入, Enter 确认, Esc 取消", + "updated_ok": "✓ 更新成功", + "no_config": " 未加载配置", + "invalid_int": "无效整数", + "section_server": "服务器", + "section_logging": "日志与统计", + "section_quota": "配额超限处理", + "section_routing": "路由", + "section_websocket": "WebSocket", + "section_ampcode": "AMP Code", + "section_other": "其他", + + // ── Auth Files ── + "auth_title": "🔑 认证文件", + "auth_help1": " [↑↓/jk] 导航 • [Enter] 展开 • [e] 启用/停用 • [d] 删除 • [r] 刷新", + "auth_help2": " [1] 编辑 prefix • [2] 编辑 proxy_url • [3] 编辑 priority", + "no_auth_files": " 无认证文件", + "confirm_delete": "⚠ 删除 %s? [y/n]", + "deleted": "已删除 %s", + "enabled": "已启用", + "disabled": "已停用", + "updated_field": "已更新 %s 的 %s", + "status_active": "活跃", + "status_disabled": "已停用", + + // ── API Keys ── + "keys_title": "🔐 API 密钥", + "keys_help": " [↑↓/jk] 导航 • [a] 添加 • [e] 编辑 • [d] 删除 • [c] 复制 • [r] 刷新", + "no_keys": " 无 API Key,按 [a] 添加", + "access_keys": "Access API Keys", + "confirm_delete_key": "⚠ 确认删除 %s? [y/n]", + "key_added": "已添加 API Key", + "key_updated": "已更新 API Key", + "key_deleted": "已删除 API Key", + "copied": "✓ 已复制到剪贴板", + "copy_failed": "✗ 复制失败", + "new_key_prompt": " New Key: ", + "edit_key_prompt": " Edit Key: ", + "enter_add": " Enter: 添加 • Esc: 取消", + "enter_save_esc": " Enter: 保存 • Esc: 取消", + + // ── OAuth ── + "oauth_title": "🔐 OAuth 登录", + "oauth_select": " 选择提供商并按 [Enter] 开始 OAuth 登录:", + "oauth_help": " [↑↓/jk] 导航 • [Enter] 登录 • [Esc] 清除状态", + "oauth_initiating": "⏳ 正在初始化 %s 登录...", + "oauth_success": "认证成功! 请刷新 Auth Files 标签查看新凭证。", + "oauth_completed": "认证流程已完成。", + "oauth_failed": "认证失败", + "oauth_timeout": "OAuth 流程超时 (5 分钟)", + "oauth_press_esc": " 按 [Esc] 取消", + "oauth_auth_url": " 授权链接:", + "oauth_remote_hint": " 远程浏览器模式:在浏览器中打开上述链接完成授权后,将回调 URL 粘贴到下方。", + "oauth_callback_url": " 回调 URL:", + "oauth_press_c": " 按 [c] 输入回调 URL • [Esc] 返回", + "oauth_submitting": "⏳ 提交回调中...", + "oauth_submit_ok": "✓ 回调已提交,等待处理...", + "oauth_submit_fail": "✗ 提交回调失败", + "oauth_waiting": " 等待认证中...", + + // ── Usage ── + "usage_title": "📈 使用统计", + "usage_help": " [r] 刷新 • [↑↓] 滚动", + "usage_no_data": " 使用数据不可用", + "usage_total_reqs": "总请求数", + "usage_total_tokens": "总 Token 数", + "usage_success": "成功", + "usage_failure": "失败", + "usage_total_token_l": "总Token", + "usage_rpm": "RPM", + "usage_tpm": "TPM", + "usage_req_by_hour": "请求趋势 (按小时)", + "usage_tok_by_hour": "Token 使用趋势 (按小时)", + "usage_req_by_day": "请求趋势 (按天)", + "usage_api_detail": "API 详细统计", + "usage_input": "输入", + "usage_output": "输出", + "usage_cached": "缓存", + "usage_reasoning": "思考", + + // ── Logs ── + "logs_title": "📋 日志", + "logs_auto_scroll": "● 自动滚动", + "logs_paused": "○ 已暂停", + "logs_filter": "过滤", + "logs_lines": "行数", + "logs_help": " [a] 自动滚动 • [c] 清除 • [1] 全部 [2] info+ [3] warn+ [4] error • [↑↓] 滚动", + "logs_waiting": " 等待日志输出...", +} + +var enStrings = map[string]string{ + // ── Common ── + "loading": "Loading...", + "refresh": "Refresh", + "save": "Save", + "cancel": "Cancel", + "confirm": "Confirm", + "yes": "Yes", + "no": "No", + "error": "Error", + "success": "Success", + "navigate": "Navigate", + "scroll": "Scroll", + "enter_save": "Enter: Save", + "esc_cancel": "Esc: Cancel", + "enter_submit": "Enter: Submit", + "press_r": "[r] Refresh", + "press_scroll": "[↑↓] Scroll", + "not_set": "(not set)", + "error_prefix": "⚠ Error: ", + + // ── Status bar ── + "status_left": " CLIProxyAPI Management TUI", + "status_right": "Tab/Shift+Tab: switch • L: lang • q/Ctrl+C: quit ", + "initializing_tui": "Initializing...", + + // ── Dashboard ── + "dashboard_title": "📊 Dashboard", + "dashboard_help": " [r] Refresh • [↑↓] Scroll", + "connected": "● Connected", + "mgmt_keys": "Mgmt Keys", + "auth_files_label": "Auth Files", + "active_suffix": "active", + "total_requests": "Requests", + "success_label": "Success", + "failure_label": "Failed", + "total_tokens": "Total Tokens", + "current_config": "Current Config", + "debug_mode": "Debug Mode", + "usage_stats": "Usage Statistics", + "log_to_file": "Log to File", + "retry_count": "Retry Count", + "proxy_url": "Proxy URL", + "routing_strategy": "Routing Strategy", + "model_stats": "Model Stats", + "model": "Model", + "requests": "Requests", + "tokens": "Tokens", + "bool_yes": "Yes ✓", + "bool_no": "No", + + // ── Config ── + "config_title": "⚙ Configuration", + "config_help1": " [↑↓/jk] Navigate • [Enter/Space] Edit • [r] Refresh", + "config_help2": " Bool: Enter to toggle • String/Int: Enter to type, Enter to confirm, Esc to cancel", + "updated_ok": "✓ Updated successfully", + "no_config": " No configuration loaded", + "invalid_int": "invalid integer", + "section_server": "Server", + "section_logging": "Logging & Stats", + "section_quota": "Quota Exceeded Handling", + "section_routing": "Routing", + "section_websocket": "WebSocket", + "section_ampcode": "AMP Code", + "section_other": "Other", + + // ── Auth Files ── + "auth_title": "🔑 Auth Files", + "auth_help1": " [↑↓/jk] Navigate • [Enter] Expand • [e] Enable/Disable • [d] Delete • [r] Refresh", + "auth_help2": " [1] Edit prefix • [2] Edit proxy_url • [3] Edit priority", + "no_auth_files": " No auth files found", + "confirm_delete": "⚠ Delete %s? [y/n]", + "deleted": "Deleted %s", + "enabled": "Enabled", + "disabled": "Disabled", + "updated_field": "Updated %s on %s", + "status_active": "active", + "status_disabled": "disabled", + + // ── API Keys ── + "keys_title": "🔐 API Keys", + "keys_help": " [↑↓/jk] Navigate • [a] Add • [e] Edit • [d] Delete • [c] Copy • [r] Refresh", + "no_keys": " No API Keys. Press [a] to add", + "access_keys": "Access API Keys", + "confirm_delete_key": "⚠ Delete %s? [y/n]", + "key_added": "API Key added", + "key_updated": "API Key updated", + "key_deleted": "API Key deleted", + "copied": "✓ Copied to clipboard", + "copy_failed": "✗ Copy failed", + "new_key_prompt": " New Key: ", + "edit_key_prompt": " Edit Key: ", + "enter_add": " Enter: Add • Esc: Cancel", + "enter_save_esc": " Enter: Save • Esc: Cancel", + + // ── OAuth ── + "oauth_title": "🔐 OAuth Login", + "oauth_select": " Select a provider and press [Enter] to start OAuth login:", + "oauth_help": " [↑↓/jk] Navigate • [Enter] Login • [Esc] Clear status", + "oauth_initiating": "⏳ Initiating %s login...", + "oauth_success": "Authentication successful! Refresh Auth Files tab to see the new credential.", + "oauth_completed": "Authentication flow completed.", + "oauth_failed": "Authentication failed", + "oauth_timeout": "OAuth flow timed out (5 minutes)", + "oauth_press_esc": " Press [Esc] to cancel", + "oauth_auth_url": " Authorization URL:", + "oauth_remote_hint": " Remote browser mode: Open the URL above in browser, paste the callback URL below after authorization.", + "oauth_callback_url": " Callback URL:", + "oauth_press_c": " Press [c] to enter callback URL • [Esc] to go back", + "oauth_submitting": "⏳ Submitting callback...", + "oauth_submit_ok": "✓ Callback submitted, waiting...", + "oauth_submit_fail": "✗ Callback submission failed", + "oauth_waiting": " Waiting for authentication...", + + // ── Usage ── + "usage_title": "📈 Usage Statistics", + "usage_help": " [r] Refresh • [↑↓] Scroll", + "usage_no_data": " Usage data not available", + "usage_total_reqs": "Total Requests", + "usage_total_tokens": "Total Tokens", + "usage_success": "Success", + "usage_failure": "Failed", + "usage_total_token_l": "Total Tokens", + "usage_rpm": "RPM", + "usage_tpm": "TPM", + "usage_req_by_hour": "Requests by Hour", + "usage_tok_by_hour": "Token Usage by Hour", + "usage_req_by_day": "Requests by Day", + "usage_api_detail": "API Detail Statistics", + "usage_input": "Input", + "usage_output": "Output", + "usage_cached": "Cached", + "usage_reasoning": "Reasoning", + + // ── Logs ── + "logs_title": "📋 Logs", + "logs_auto_scroll": "● AUTO-SCROLL", + "logs_paused": "○ PAUSED", + "logs_filter": "Filter", + "logs_lines": "Lines", + "logs_help": " [a] Auto-scroll • [c] Clear • [1] All [2] info+ [3] warn+ [4] error • [↑↓] Scroll", + "logs_waiting": " Waiting for log output...", +} diff --git a/internal/tui/keys_tab.go b/internal/tui/keys_tab.go index 20e9e0f0..770f7f1e 100644 --- a/internal/tui/keys_tab.go +++ b/internal/tui/keys_tab.go @@ -4,19 +4,36 @@ import ( "fmt" "strings" + "github.com/atotto/clipboard" + "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" ) -// keysTabModel displays API keys from all providers. +// keysTabModel displays and manages API keys. type keysTabModel struct { client *Client viewport viewport.Model - content string + keys []string + gemini []map[string]any + claude []map[string]any + codex []map[string]any + vertex []map[string]any + openai []map[string]any err error width int height int ready bool + cursor int + confirm int // -1 = no deletion pending + status string + + // Editing / Adding + editing bool + adding bool + editIdx int + editInput textinput.Model } type keysDataMsg struct { @@ -29,9 +46,19 @@ type keysDataMsg struct { err error } +type keyActionMsg struct { + action string + err error +} + func newKeysTabModel(client *Client) keysTabModel { + ti := textinput.New() + ti.CharLimit = 512 + ti.Prompt = " Key: " return keysTabModel{ - client: client, + client: client, + confirm: -1, + editInput: ti, } } @@ -41,44 +68,185 @@ func (m keysTabModel) Init() tea.Cmd { func (m keysTabModel) fetchKeys() tea.Msg { result := keysDataMsg{} - apiKeys, err := m.client.GetAPIKeys() if err != nil { result.err = err return result } result.apiKeys = apiKeys - - // Fetch all key types, ignoring individual errors (they may not be configured) result.gemini, _ = m.client.GetGeminiKeys() result.claude, _ = m.client.GetClaudeKeys() result.codex, _ = m.client.GetCodexKeys() result.vertex, _ = m.client.GetVertexKeys() result.openai, _ = m.client.GetOpenAICompat() - return result } func (m keysTabModel) Update(msg tea.Msg) (keysTabModel, tea.Cmd) { switch msg := msg.(type) { + case localeChangedMsg: + m.viewport.SetContent(m.renderContent()) + return m, nil case keysDataMsg: if msg.err != nil { m.err = msg.err - m.content = errorStyle.Render("⚠ Error: " + msg.err.Error()) } else { m.err = nil - m.content = m.renderKeys(msg) + m.keys = msg.apiKeys + m.gemini = msg.gemini + m.claude = msg.claude + m.codex = msg.codex + m.vertex = msg.vertex + m.openai = msg.openai + if m.cursor >= len(m.keys) { + m.cursor = max(0, len(m.keys)-1) + } } - m.viewport.SetContent(m.content) + m.viewport.SetContent(m.renderContent()) return m, nil - case tea.KeyMsg: - if msg.String() == "r" { - return m, m.fetchKeys + case keyActionMsg: + if msg.err != nil { + m.status = errorStyle.Render("✗ " + msg.err.Error()) + } else { + m.status = successStyle.Render("✓ " + msg.action) + } + m.confirm = -1 + m.viewport.SetContent(m.renderContent()) + return m, m.fetchKeys + + case tea.KeyMsg: + // ---- Editing / Adding mode ---- + if m.editing || m.adding { + switch msg.String() { + case "enter": + value := strings.TrimSpace(m.editInput.Value()) + if value == "" { + m.editing = false + m.adding = false + m.editInput.Blur() + m.viewport.SetContent(m.renderContent()) + return m, nil + } + isAdding := m.adding + editIdx := m.editIdx + m.editing = false + m.adding = false + m.editInput.Blur() + if isAdding { + return m, func() tea.Msg { + err := m.client.AddAPIKey(value) + if err != nil { + return keyActionMsg{err: err} + } + return keyActionMsg{action: T("key_added")} + } + } + return m, func() tea.Msg { + err := m.client.EditAPIKey(editIdx, value) + if err != nil { + return keyActionMsg{err: err} + } + return keyActionMsg{action: T("key_updated")} + } + case "esc": + m.editing = false + m.adding = false + m.editInput.Blur() + m.viewport.SetContent(m.renderContent()) + return m, nil + default: + var cmd tea.Cmd + m.editInput, cmd = m.editInput.Update(msg) + m.viewport.SetContent(m.renderContent()) + return m, cmd + } + } + + // ---- Delete confirmation ---- + if m.confirm >= 0 { + switch msg.String() { + case "y", "Y": + idx := m.confirm + m.confirm = -1 + return m, func() tea.Msg { + err := m.client.DeleteAPIKey(idx) + if err != nil { + return keyActionMsg{err: err} + } + return keyActionMsg{action: T("key_deleted")} + } + case "n", "N", "esc": + m.confirm = -1 + m.viewport.SetContent(m.renderContent()) + return m, nil + } + return m, nil + } + + // ---- Normal mode ---- + switch msg.String() { + case "j", "down": + if len(m.keys) > 0 { + m.cursor = (m.cursor + 1) % len(m.keys) + m.viewport.SetContent(m.renderContent()) + } + return m, nil + case "k", "up": + if len(m.keys) > 0 { + m.cursor = (m.cursor - 1 + len(m.keys)) % len(m.keys) + m.viewport.SetContent(m.renderContent()) + } + return m, nil + case "a": + // Add new key + m.adding = true + m.editing = false + m.editInput.SetValue("") + m.editInput.Prompt = T("new_key_prompt") + m.editInput.Focus() + m.viewport.SetContent(m.renderContent()) + return m, textinput.Blink + case "e": + // Edit selected key + if m.cursor < len(m.keys) { + m.editing = true + m.adding = false + m.editIdx = m.cursor + m.editInput.SetValue(m.keys[m.cursor]) + m.editInput.Prompt = T("edit_key_prompt") + m.editInput.Focus() + m.viewport.SetContent(m.renderContent()) + return m, textinput.Blink + } + return m, nil + case "d": + // Delete selected key + if m.cursor < len(m.keys) { + m.confirm = m.cursor + m.viewport.SetContent(m.renderContent()) + } + return m, nil + case "c": + // Copy selected key to clipboard + if m.cursor < len(m.keys) { + key := m.keys[m.cursor] + if err := clipboard.WriteAll(key); err != nil { + m.status = errorStyle.Render(T("copy_failed") + ": " + err.Error()) + } else { + m.status = successStyle.Render(T("copied")) + } + m.viewport.SetContent(m.renderContent()) + } + return m, nil + case "r": + m.status = "" + return m, m.fetchKeys + default: + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd } - var cmd tea.Cmd - m.viewport, cmd = m.viewport.Update(msg) - return m, cmd } var cmd tea.Cmd @@ -89,9 +257,10 @@ func (m keysTabModel) Update(msg tea.Msg) (keysTabModel, tea.Cmd) { func (m *keysTabModel) SetSize(w, h int) { m.width = w m.height = h + m.editInput.Width = w - 16 if !m.ready { m.viewport = viewport.New(w, h) - m.viewport.SetContent(m.content) + m.viewport.SetContent(m.renderContent()) m.ready = true } else { m.viewport.Width = w @@ -101,40 +270,83 @@ func (m *keysTabModel) SetSize(w, h int) { func (m keysTabModel) View() string { if !m.ready { - return "Loading..." + return T("loading") } return m.viewport.View() } -func (m keysTabModel) renderKeys(data keysDataMsg) string { +func (m keysTabModel) renderContent() string { var sb strings.Builder - sb.WriteString(titleStyle.Render("🔐 API Keys")) - sb.WriteString("\n\n") - - // API Keys (access keys) - renderSection(&sb, "Access API Keys", len(data.apiKeys)) - for i, key := range data.apiKeys { - sb.WriteString(fmt.Sprintf(" %d. %s\n", i+1, maskKey(key))) - } + sb.WriteString(titleStyle.Render(T("keys_title"))) + sb.WriteString("\n") + sb.WriteString(helpStyle.Render(T("keys_help"))) + sb.WriteString("\n") + sb.WriteString(strings.Repeat("─", m.width)) sb.WriteString("\n") - // Gemini Keys - renderProviderKeys(&sb, "Gemini API Keys", data.gemini) + if m.err != nil { + sb.WriteString(errorStyle.Render(T("error_prefix") + m.err.Error())) + sb.WriteString("\n") + return sb.String() + } - // Claude Keys - renderProviderKeys(&sb, "Claude API Keys", data.claude) + // ━━━ Access API Keys (interactive) ━━━ + sb.WriteString(tableHeaderStyle.Render(fmt.Sprintf(" %s (%d)", T("access_keys"), len(m.keys)))) + sb.WriteString("\n") - // Codex Keys - renderProviderKeys(&sb, "Codex API Keys", data.codex) + if len(m.keys) == 0 { + sb.WriteString(subtitleStyle.Render(T("no_keys"))) + sb.WriteString("\n") + } - // Vertex Keys - renderProviderKeys(&sb, "Vertex API Keys", data.vertex) + for i, key := range m.keys { + cursor := " " + rowStyle := lipgloss.NewStyle() + if i == m.cursor { + cursor = "▸ " + rowStyle = lipgloss.NewStyle().Bold(true) + } - // OpenAI Compatibility - if len(data.openai) > 0 { - renderSection(&sb, "OpenAI Compatibility", len(data.openai)) - for i, entry := range data.openai { + row := fmt.Sprintf("%s%d. %s", cursor, i+1, maskKey(key)) + sb.WriteString(rowStyle.Render(row)) + sb.WriteString("\n") + + // Delete confirmation + if m.confirm == i { + sb.WriteString(warningStyle.Render(fmt.Sprintf(" "+T("confirm_delete_key"), maskKey(key)))) + sb.WriteString("\n") + } + + // Edit input + if m.editing && m.editIdx == i { + sb.WriteString(m.editInput.View()) + sb.WriteString("\n") + sb.WriteString(helpStyle.Render(T("enter_save_esc"))) + sb.WriteString("\n") + } + } + + // Add input + if m.adding { + sb.WriteString("\n") + sb.WriteString(m.editInput.View()) + sb.WriteString("\n") + sb.WriteString(helpStyle.Render(T("enter_add"))) + sb.WriteString("\n") + } + + sb.WriteString("\n") + + // ━━━ Provider Keys (read-only display) ━━━ + renderProviderKeys(&sb, "Gemini API Keys", m.gemini) + renderProviderKeys(&sb, "Claude API Keys", m.claude) + renderProviderKeys(&sb, "Codex API Keys", m.codex) + renderProviderKeys(&sb, "Vertex API Keys", m.vertex) + + if len(m.openai) > 0 { + renderSection(&sb, "OpenAI Compatibility", len(m.openai)) + for i, entry := range m.openai { name := getString(entry, "name") baseURL := getString(entry, "base-url") prefix := getString(entry, "prefix") @@ -150,7 +362,10 @@ func (m keysTabModel) renderKeys(data keysDataMsg) string { sb.WriteString("\n") } - sb.WriteString(helpStyle.Render("Press [r] to refresh • [↑↓] to scroll")) + if m.status != "" { + sb.WriteString(m.status) + sb.WriteString("\n") + } return sb.String() } diff --git a/internal/tui/logs_tab.go b/internal/tui/logs_tab.go index 9281d472..ec7bdfc5 100644 --- a/internal/tui/logs_tab.go +++ b/internal/tui/logs_tab.go @@ -47,6 +47,9 @@ func (m logsTabModel) waitForLog() tea.Msg { func (m logsTabModel) Update(msg tea.Msg) (logsTabModel, tea.Cmd) { switch msg := msg.(type) { + case localeChangedMsg: + m.viewport.SetContent(m.renderLogs()) + return m, nil case logLineMsg: m.lines = append(m.lines, string(msg)) if len(m.lines) > m.maxLines { @@ -122,7 +125,7 @@ func (m *logsTabModel) SetSize(w, h int) { func (m logsTabModel) View() string { if !m.ready { - return "Loading logs..." + return T("loading") } return m.viewport.View() } @@ -130,26 +133,26 @@ func (m logsTabModel) View() string { func (m logsTabModel) renderLogs() string { var sb strings.Builder - scrollStatus := successStyle.Render("● AUTO-SCROLL") + scrollStatus := successStyle.Render(T("logs_auto_scroll")) if !m.autoScroll { - scrollStatus = warningStyle.Render("○ PAUSED") + scrollStatus = warningStyle.Render(T("logs_paused")) } filterLabel := "ALL" if m.filter != "" { filterLabel = strings.ToUpper(m.filter) + "+" } - header := fmt.Sprintf(" 📋 Logs %s Filter: %s Lines: %d", - scrollStatus, filterLabel, len(m.lines)) + header := fmt.Sprintf(" %s %s %s: %s %s: %d", + T("logs_title"), scrollStatus, T("logs_filter"), filterLabel, T("logs_lines"), len(m.lines)) sb.WriteString(titleStyle.Render(header)) sb.WriteString("\n") - sb.WriteString(helpStyle.Render(" [a]uto-scroll • [c]lear • [1]all [2]info+ [3]warn+ [4]error • [↑↓] scroll")) + sb.WriteString(helpStyle.Render(T("logs_help"))) sb.WriteString("\n") sb.WriteString(strings.Repeat("─", m.width)) sb.WriteString("\n") if len(m.lines) == 0 { - sb.WriteString(subtitleStyle.Render("\n Waiting for log output...")) + sb.WriteString(subtitleStyle.Render(T("logs_waiting"))) return sb.String() } diff --git a/internal/tui/oauth_tab.go b/internal/tui/oauth_tab.go index 2f320c2d..3989e3d8 100644 --- a/internal/tui/oauth_tab.go +++ b/internal/tui/oauth_tab.go @@ -93,6 +93,9 @@ func (m oauthTabModel) Init() tea.Cmd { func (m oauthTabModel) Update(msg tea.Msg) (oauthTabModel, tea.Cmd) { switch msg := msg.(type) { + case localeChangedMsg: + m.viewport.SetContent(m.renderContent()) + return m, nil case oauthStartMsg: if msg.err != nil { m.state = oauthError @@ -133,9 +136,9 @@ func (m oauthTabModel) Update(msg tea.Msg) (oauthTabModel, tea.Cmd) { case oauthCallbackSubmitMsg: if msg.err != nil { - m.message = errorStyle.Render("✗ 提交回调失败: " + msg.err.Error()) + m.message = errorStyle.Render(T("oauth_submit_fail") + ": " + msg.err.Error()) } else { - m.message = successStyle.Render("✓ 回调已提交,等待处理...") + m.message = successStyle.Render(T("oauth_submit_ok")) } m.viewport.SetContent(m.renderContent()) return m, nil @@ -151,7 +154,7 @@ func (m oauthTabModel) Update(msg tea.Msg) (oauthTabModel, tea.Cmd) { } m.inputActive = false m.callbackInput.Blur() - m.message = warningStyle.Render("⏳ 提交回调中...") + m.message = warningStyle.Render(T("oauth_submitting")) m.viewport.SetContent(m.renderContent()) return m, m.submitCallback(callbackURL) case "esc": @@ -217,7 +220,7 @@ func (m oauthTabModel) Update(msg tea.Msg) (oauthTabModel, tea.Cmd) { if m.cursor >= 0 && m.cursor < len(oauthProviders) { provider := oauthProviders[m.cursor] m.state = oauthPending - m.message = warningStyle.Render("⏳ 正在初始化 " + provider.name + " 登录...") + m.message = warningStyle.Render(fmt.Sprintf(T("oauth_initiating"), provider.name)) m.viewport.SetContent(m.renderContent()) return m, m.startOAuth(provider) } @@ -307,7 +310,7 @@ func (m oauthTabModel) pollOAuthStatus(state string) tea.Cmd { deadline := time.Now().Add(5 * time.Minute) for { if time.Now().After(deadline) { - return oauthPollMsg{done: false, err: fmt.Errorf("OAuth flow timed out (5 minutes)")} + return oauthPollMsg{done: false, err: fmt.Errorf("%s", T("oauth_timeout"))} } time.Sleep(2 * time.Second) @@ -321,19 +324,19 @@ func (m oauthTabModel) pollOAuthStatus(state string) tea.Cmd { case "ok": return oauthPollMsg{ done: true, - message: "认证成功! 请刷新 Auth Files 标签查看新凭证。", + message: T("oauth_success"), } case "error": return oauthPollMsg{ done: false, - err: fmt.Errorf("认证失败: %s", errMsg), + err: fmt.Errorf("%s: %s", T("oauth_failed"), errMsg), } case "wait": continue default: return oauthPollMsg{ done: true, - message: "认证流程已完成。", + message: T("oauth_completed"), } } } @@ -356,7 +359,7 @@ func (m *oauthTabModel) SetSize(w, h int) { func (m oauthTabModel) View() string { if !m.ready { - return "Loading..." + return T("loading") } return m.viewport.View() } @@ -364,7 +367,7 @@ func (m oauthTabModel) View() string { func (m oauthTabModel) renderContent() string { var sb strings.Builder - sb.WriteString(titleStyle.Render("🔐 OAuth 登录")) + sb.WriteString(titleStyle.Render(T("oauth_title"))) sb.WriteString("\n\n") if m.message != "" { @@ -379,11 +382,11 @@ func (m oauthTabModel) renderContent() string { } if m.state == oauthPending { - sb.WriteString(helpStyle.Render(" Press [Esc] to cancel")) + sb.WriteString(helpStyle.Render(T("oauth_press_esc"))) return sb.String() } - sb.WriteString(helpStyle.Render(" 选择提供商并按 [Enter] 开始 OAuth 登录:")) + sb.WriteString(helpStyle.Render(T("oauth_select"))) sb.WriteString("\n\n") for i, p := range oauthProviders { @@ -404,7 +407,7 @@ func (m oauthTabModel) renderContent() string { } sb.WriteString("\n") - sb.WriteString(helpStyle.Render(" [↑↓/jk] 导航 • [Enter] 登录 • [Esc] 清除状态")) + sb.WriteString(helpStyle.Render(T("oauth_help"))) return sb.String() } @@ -417,7 +420,7 @@ func (m oauthTabModel) renderRemoteMode() string { sb.WriteString("\n\n") // Auth URL section - sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorInfo).Render(" 授权链接:")) + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorInfo).Render(T("oauth_auth_url"))) sb.WriteString("\n") // Wrap URL to fit terminal width @@ -432,23 +435,23 @@ func (m oauthTabModel) renderRemoteMode() string { } sb.WriteString("\n") - sb.WriteString(helpStyle.Render(" 远程浏览器模式:在浏览器中打开上述链接完成授权后,将回调 URL 粘贴到下方。")) + sb.WriteString(helpStyle.Render(T("oauth_remote_hint"))) sb.WriteString("\n\n") // Callback URL input - sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorInfo).Render(" 回调 URL:")) + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorInfo).Render(T("oauth_callback_url"))) sb.WriteString("\n") if m.inputActive { sb.WriteString(m.callbackInput.View()) sb.WriteString("\n") - sb.WriteString(helpStyle.Render(" Enter: 提交 • Esc: 取消输入")) + sb.WriteString(helpStyle.Render(" " + T("enter_submit") + " • " + T("esc_cancel"))) } else { - sb.WriteString(helpStyle.Render(" 按 [c] 输入回调 URL • [Esc] 返回")) + sb.WriteString(helpStyle.Render(T("oauth_press_c"))) } sb.WriteString("\n\n") - sb.WriteString(warningStyle.Render(" 等待认证中...")) + sb.WriteString(warningStyle.Render(T("oauth_waiting"))) return sb.String() } diff --git a/internal/tui/usage_tab.go b/internal/tui/usage_tab.go index ebbf832d..a40a760f 100644 --- a/internal/tui/usage_tab.go +++ b/internal/tui/usage_tab.go @@ -43,6 +43,9 @@ func (m usageTabModel) fetchData() tea.Msg { func (m usageTabModel) Update(msg tea.Msg) (usageTabModel, tea.Cmd) { switch msg := msg.(type) { + case localeChangedMsg: + m.viewport.SetContent(m.renderContent()) + return m, nil case usageDataMsg: if msg.err != nil { m.err = msg.err @@ -82,7 +85,7 @@ func (m *usageTabModel) SetSize(w, h int) { func (m usageTabModel) View() string { if !m.ready { - return "Loading..." + return T("loading") } return m.viewport.View() } @@ -90,9 +93,9 @@ func (m usageTabModel) View() string { func (m usageTabModel) renderContent() string { var sb strings.Builder - sb.WriteString(titleStyle.Render("📈 使用统计")) + sb.WriteString(titleStyle.Render(T("usage_title"))) sb.WriteString("\n") - sb.WriteString(helpStyle.Render(" [r] refresh • [↑↓] scroll")) + sb.WriteString(helpStyle.Render(T("usage_help"))) sb.WriteString("\n\n") if m.err != nil { @@ -102,14 +105,14 @@ func (m usageTabModel) renderContent() string { } if m.usage == nil { - sb.WriteString(subtitleStyle.Render(" Usage data not available")) + sb.WriteString(subtitleStyle.Render(T("usage_no_data"))) sb.WriteString("\n") return sb.String() } usageMap, _ := m.usage["usage"].(map[string]any) if usageMap == nil { - sb.WriteString(subtitleStyle.Render(" No usage data")) + sb.WriteString(subtitleStyle.Render(T("usage_no_data"))) sb.WriteString("\n") return sb.String() } @@ -137,17 +140,17 @@ func (m usageTabModel) renderContent() string { // Total Requests card1 := cardStyle.Copy().BorderForeground(lipgloss.Color("111")).Render(fmt.Sprintf( "%s\n%s\n%s", - lipgloss.NewStyle().Foreground(colorMuted).Render("总请求数"), + lipgloss.NewStyle().Foreground(colorMuted).Render(T("usage_total_reqs")), lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("111")).Render(fmt.Sprintf("%d", totalReqs)), - lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("● 成功: %d ● 失败: %d", successCnt, failureCnt)), + lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("● %s: %d ● %s: %d", T("usage_success"), successCnt, T("usage_failure"), failureCnt)), )) // Total Tokens card2 := cardStyle.Copy().BorderForeground(lipgloss.Color("214")).Render(fmt.Sprintf( "%s\n%s\n%s", - lipgloss.NewStyle().Foreground(colorMuted).Render("总 Token 数"), + lipgloss.NewStyle().Foreground(colorMuted).Render(T("usage_total_tokens")), lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")).Render(formatLargeNumber(totalTokens)), - lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("总Token: %s", formatLargeNumber(totalTokens))), + lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("%s: %s", T("usage_total_token_l"), formatLargeNumber(totalTokens))), )) // RPM @@ -159,9 +162,9 @@ func (m usageTabModel) renderContent() string { } card3 := cardStyle.Copy().BorderForeground(lipgloss.Color("76")).Render(fmt.Sprintf( "%s\n%s\n%s", - lipgloss.NewStyle().Foreground(colorMuted).Render("RPM"), + lipgloss.NewStyle().Foreground(colorMuted).Render(T("usage_rpm")), lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("76")).Render(fmt.Sprintf("%.2f", rpm)), - lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("总请求数: %d", totalReqs)), + lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("%s: %d", T("usage_total_reqs"), totalReqs)), )) // TPM @@ -173,9 +176,9 @@ func (m usageTabModel) renderContent() string { } card4 := cardStyle.Copy().BorderForeground(lipgloss.Color("170")).Render(fmt.Sprintf( "%s\n%s\n%s", - lipgloss.NewStyle().Foreground(colorMuted).Render("TPM"), + lipgloss.NewStyle().Foreground(colorMuted).Render(T("usage_tpm")), lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("170")).Render(fmt.Sprintf("%.2f", tpm)), - lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("总Token数: %s", formatLargeNumber(totalTokens))), + lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("%s: %s", T("usage_total_tokens"), formatLargeNumber(totalTokens))), )) sb.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, card1, " ", card2, " ", card3, " ", card4)) @@ -183,7 +186,7 @@ func (m usageTabModel) renderContent() string { // ━━━ Requests by Hour (ASCII bar chart) ━━━ if rByH, ok := usageMap["requests_by_hour"].(map[string]any); ok && len(rByH) > 0 { - sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render("请求趋势 (按小时)")) + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(T("usage_req_by_hour"))) sb.WriteString("\n") sb.WriteString(strings.Repeat("─", minInt(m.width, 60))) sb.WriteString("\n") @@ -193,7 +196,7 @@ func (m usageTabModel) renderContent() string { // ━━━ Tokens by Hour ━━━ if tByH, ok := usageMap["tokens_by_hour"].(map[string]any); ok && len(tByH) > 0 { - sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render("Token 使用趋势 (按小时)")) + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(T("usage_tok_by_hour"))) sb.WriteString("\n") sb.WriteString(strings.Repeat("─", minInt(m.width, 60))) sb.WriteString("\n") @@ -203,7 +206,7 @@ func (m usageTabModel) renderContent() string { // ━━━ Requests by Day ━━━ if rByD, ok := usageMap["requests_by_day"].(map[string]any); ok && len(rByD) > 0 { - sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render("请求趋势 (按天)")) + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(T("usage_req_by_day"))) sb.WriteString("\n") sb.WriteString(strings.Repeat("─", minInt(m.width, 60))) sb.WriteString("\n") @@ -213,12 +216,12 @@ func (m usageTabModel) renderContent() string { // ━━━ API Detail Stats ━━━ if apis, ok := usageMap["apis"].(map[string]any); ok && len(apis) > 0 { - sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render("API 详细统计")) + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(T("usage_api_detail"))) sb.WriteString("\n") sb.WriteString(strings.Repeat("─", minInt(m.width, 80))) sb.WriteString("\n") - header := fmt.Sprintf(" %-30s %10s %12s", "API", "Requests", "Tokens") + header := fmt.Sprintf(" %-30s %10s %12s", "API", T("requests"), T("tokens")) sb.WriteString(tableHeaderStyle.Render(header)) sb.WriteString("\n") @@ -289,16 +292,16 @@ func (m usageTabModel) renderTokenBreakdown(modelStats map[string]any) string { parts := []string{} if inputTotal > 0 { - parts = append(parts, fmt.Sprintf("输入:%s", formatLargeNumber(inputTotal))) + parts = append(parts, fmt.Sprintf("%s:%s", T("usage_input"), formatLargeNumber(inputTotal))) } if outputTotal > 0 { - parts = append(parts, fmt.Sprintf("输出:%s", formatLargeNumber(outputTotal))) + parts = append(parts, fmt.Sprintf("%s:%s", T("usage_output"), formatLargeNumber(outputTotal))) } if cachedTotal > 0 { - parts = append(parts, fmt.Sprintf("缓存:%s", formatLargeNumber(cachedTotal))) + parts = append(parts, fmt.Sprintf("%s:%s", T("usage_cached"), formatLargeNumber(cachedTotal))) } if reasoningTotal > 0 { - parts = append(parts, fmt.Sprintf("思考:%s", formatLargeNumber(reasoningTotal))) + parts = append(parts, fmt.Sprintf("%s:%s", T("usage_reasoning"), formatLargeNumber(reasoningTotal))) } return fmt.Sprintf(" │ %s\n",