feat(tui): add i18n

This commit is contained in:
lhpqaq
2026-02-15 15:42:59 +08:00
parent 54ad7c1b6b
commit f31f7f701a
11 changed files with 793 additions and 148 deletions

2
go.mod
View File

@@ -4,6 +4,7 @@ go 1.24.2
require ( require (
github.com/andybalholm/brotli v1.0.6 github.com/andybalholm/brotli v1.0.6
github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/lipgloss v1.1.0
@@ -34,7 +35,6 @@ require (
cloud.google.com/go/compute/metadata v0.3.0 // indirect cloud.google.com/go/compute/metadata v0.3.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // 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/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect

View File

@@ -20,8 +20,6 @@ const (
tabLogs 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. // App is the root bubbletea model that contains all tab sub-models.
type App struct { type App struct {
activeTab int activeTab int
@@ -50,7 +48,7 @@ func NewApp(port int, secretKey string, hook *LogHook) App {
client := NewClient(port, secretKey) client := NewClient(port, secretKey)
return App{ return App{
activeTab: tabDashboard, activeTab: tabDashboard,
tabs: tabNames, tabs: TabNames(),
dashboard: newDashboardModel(client), dashboard: newDashboardModel(client),
config: newConfigTabModel(client), config: newConfigTabModel(client),
auth: newAuthTabModel(client), auth: newAuthTabModel(client),
@@ -102,13 +100,50 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if a.activeTab != tabLogs { if a.activeTab != tabLogs {
return a, tea.Quit 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": case "tab":
prevTab := a.activeTab prevTab := a.activeTab
a.activeTab = (a.activeTab + 1) % len(a.tabs) a.activeTab = (a.activeTab + 1) % len(a.tabs)
a.tabs = TabNames()
return a, a.initTabIfNeeded(prevTab) return a, a.initTabIfNeeded(prevTab)
case "shift+tab": case "shift+tab":
prevTab := a.activeTab prevTab := a.activeTab
a.activeTab = (a.activeTab - 1 + len(a.tabs)) % len(a.tabs) a.activeTab = (a.activeTab - 1 + len(a.tabs)) % len(a.tabs)
a.tabs = TabNames()
return a, a.initTabIfNeeded(prevTab) return a, a.initTabIfNeeded(prevTab)
} }
} }
@@ -145,6 +180,9 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, 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 { func (a *App) initTabIfNeeded(_ int) tea.Cmd {
if a.initialized[a.activeTab] { if a.initialized[a.activeTab] {
return nil return nil
@@ -171,7 +209,7 @@ func (a *App) initTabIfNeeded(_ int) tea.Cmd {
func (a App) View() string { func (a App) View() string {
if !a.ready { if !a.ready {
return "Initializing TUI..." return T("initializing_tui")
} }
var sb strings.Builder var sb strings.Builder
@@ -219,8 +257,8 @@ func (a App) renderTabBar() string {
} }
func (a App) renderStatusBar() string { func (a App) renderStatusBar() string {
left := " CLIProxyAPI Management TUI" left := T("status_left")
right := "Tab/Shift+Tab: switch • q/Ctrl+C: quit " right := T("status_right")
gap := a.width - lipgloss.Width(left) - lipgloss.Width(right) gap := a.width - lipgloss.Width(left) - lipgloss.Width(right)
if gap < 0 { if gap < 0 {
gap = 0 gap = 0

View File

@@ -76,6 +76,9 @@ func (m authTabModel) fetchFiles() tea.Msg {
func (m authTabModel) Update(msg tea.Msg) (authTabModel, tea.Cmd) { func (m authTabModel) Update(msg tea.Msg) (authTabModel, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case localeChangedMsg:
m.viewport.SetContent(m.renderContent())
return m, nil
case authFilesMsg: case authFilesMsg:
if msg.err != nil { if msg.err != nil {
m.err = msg.err m.err = msg.err
@@ -122,7 +125,7 @@ func (m authTabModel) Update(msg tea.Msg) (authTabModel, tea.Cmd) {
if err != nil { if err != nil {
return authActionMsg{err: err} 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": case "esc":
m.editing = false m.editing = false
@@ -150,7 +153,7 @@ func (m authTabModel) Update(msg tea.Msg) (authTabModel, tea.Cmd) {
if err != nil { if err != nil {
return authActionMsg{err: err} 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()) m.viewport.SetContent(m.renderContent())
@@ -202,9 +205,9 @@ func (m authTabModel) Update(msg tea.Msg) (authTabModel, tea.Cmd) {
if err != nil { if err != nil {
return authActionMsg{err: err} return authActionMsg{err: err}
} }
action := "Enabled" action := T("enabled")
if newDisabled { if newDisabled {
action = "Disabled" action = T("disabled")
} }
return authActionMsg{action: fmt.Sprintf("%s %s", action, name)} 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 { func (m authTabModel) View() string {
if !m.ready { if !m.ready {
return "Loading..." return T("loading")
} }
return m.viewport.View() return m.viewport.View()
} }
@@ -275,11 +278,11 @@ func (m authTabModel) View() string {
func (m authTabModel) renderContent() string { func (m authTabModel) renderContent() string {
var sb strings.Builder var sb strings.Builder
sb.WriteString(titleStyle.Render("🔑 Auth Files")) sb.WriteString(titleStyle.Render(T("auth_title")))
sb.WriteString("\n") 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("\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("\n")
sb.WriteString(strings.Repeat("─", m.width)) sb.WriteString(strings.Repeat("─", m.width))
sb.WriteString("\n") sb.WriteString("\n")
@@ -291,7 +294,7 @@ func (m authTabModel) renderContent() string {
} }
if len(m.files) == 0 { 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") sb.WriteString("\n")
return sb.String() return sb.String()
} }
@@ -303,10 +306,10 @@ func (m authTabModel) renderContent() string {
disabled := getBool(f, "disabled") disabled := getBool(f, "disabled")
statusIcon := successStyle.Render("●") statusIcon := successStyle.Render("●")
statusText := "active" statusText := T("status_active")
if disabled { if disabled {
statusIcon = lipgloss.NewStyle().Foreground(colorMuted).Render("○") statusIcon = lipgloss.NewStyle().Foreground(colorMuted).Render("○")
statusText = "disabled" statusText = T("status_disabled")
} }
cursor := " " cursor := " "
@@ -332,7 +335,7 @@ func (m authTabModel) renderContent() string {
// Delete confirmation // Delete confirmation
if m.confirm == i { 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") sb.WriteString("\n")
} }
@@ -340,7 +343,7 @@ func (m authTabModel) renderContent() string {
if m.editing && i == m.cursor { if m.editing && i == m.cursor {
sb.WriteString(m.editInput.View()) sb.WriteString(m.editInput.View())
sb.WriteString("\n") sb.WriteString("\n")
sb.WriteString(helpStyle.Render(" Enter: save • Esc: cancel")) sb.WriteString(helpStyle.Render(" " + T("enter_save") + " • " + T("esc_cancel")))
sb.WriteString("\n") sb.WriteString("\n")
} }
@@ -398,7 +401,7 @@ func (m authTabModel) renderDetail(f map[string]any) string {
val := getAnyString(f, field.key) val := getAnyString(f, field.key)
if val == "" || val == "<nil>" { if val == "" || val == "<nil>" {
if field.editable { if field.editable {
val = "(not set)" val = T("not_set")
} else { } else {
continue continue
} }

View File

@@ -206,6 +206,34 @@ func (c *Client) GetAPIKeys() ([]string, error) {
return result, nil 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. // GetGeminiKeys fetches Gemini API keys.
// API returns {"gemini-api-key": [...]}. // API returns {"gemini-api-key": [...]}.
func (c *Client) GetGeminiKeys() ([]map[string]any, error) { func (c *Client) GetGeminiKeys() ([]map[string]any, error) {

View File

@@ -64,6 +64,9 @@ func (m configTabModel) fetchConfig() tea.Msg {
func (m configTabModel) Update(msg tea.Msg) (configTabModel, tea.Cmd) { func (m configTabModel) Update(msg tea.Msg) (configTabModel, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case localeChangedMsg:
m.viewport.SetContent(m.renderContent())
return m, nil
case configDataMsg: case configDataMsg:
if msg.err != nil { if msg.err != nil {
m.err = msg.err m.err = msg.err
@@ -79,7 +82,7 @@ func (m configTabModel) Update(msg tea.Msg) (configTabModel, tea.Cmd) {
if msg.err != nil { if msg.err != nil {
m.message = errorStyle.Render("✗ " + msg.err.Error()) m.message = errorStyle.Render("✗ " + msg.err.Error())
} else { } else {
m.message = successStyle.Render("✓ Updated successfully") m.message = successStyle.Render(T("updated_ok"))
} }
m.viewport.SetContent(m.renderContent()) m.viewport.SetContent(m.renderContent())
// Refresh config from server // Refresh config from server
@@ -178,7 +181,7 @@ func (m configTabModel) submitEdit(idx int, newValue string) tea.Cmd {
case "int": case "int":
v, parseErr := strconv.Atoi(newValue) v, parseErr := strconv.Atoi(newValue)
if parseErr != nil { 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) err = m.client.PutIntField(f.apiPath, v)
case "string": case "string":
@@ -214,7 +217,7 @@ func (m *configTabModel) ensureCursorVisible() {
func (m configTabModel) View() string { func (m configTabModel) View() string {
if !m.ready { if !m.ready {
return "Loading..." return T("loading")
} }
return m.viewport.View() return m.viewport.View()
} }
@@ -222,7 +225,7 @@ func (m configTabModel) View() string {
func (m configTabModel) renderContent() string { func (m configTabModel) renderContent() string {
var sb strings.Builder var sb strings.Builder
sb.WriteString(titleStyle.Render("⚙ Configuration")) sb.WriteString(titleStyle.Render(T("config_title")))
sb.WriteString("\n") sb.WriteString("\n")
if m.message != "" { if m.message != "" {
@@ -230,9 +233,9 @@ func (m configTabModel) renderContent() string {
sb.WriteString("\n") 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("\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") sb.WriteString("\n\n")
if m.err != nil { if m.err != nil {
@@ -241,7 +244,7 @@ func (m configTabModel) renderContent() string {
} }
if len(m.fields) == 0 { if len(m.fields) == 0 {
sb.WriteString(subtitleStyle.Render(" No configuration loaded")) sb.WriteString(subtitleStyle.Render(T("no_config")))
return sb.String() return sb.String()
} }
@@ -341,23 +344,23 @@ func (m configTabModel) parseConfig(cfg map[string]any) []configField {
func fieldSection(apiPath string) string { func fieldSection(apiPath string) string {
if strings.HasPrefix(apiPath, "ampcode/") { if strings.HasPrefix(apiPath, "ampcode/") {
return "AMP Code" return T("section_ampcode")
} }
if strings.HasPrefix(apiPath, "quota-exceeded/") { if strings.HasPrefix(apiPath, "quota-exceeded/") {
return "Quota Exceeded Handling" return T("section_quota")
} }
if strings.HasPrefix(apiPath, "routing/") { if strings.HasPrefix(apiPath, "routing/") {
return "Routing" return T("section_routing")
} }
switch apiPath { switch apiPath {
case "port", "host", "debug", "proxy-url", "request-retry", "max-retry-interval", "force-model-prefix": 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": 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": case "ws-auth":
return "WebSocket" return T("section_websocket")
default: 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 { func maskIfNotEmpty(s string) string {
if s == "" { if s == "" {
return "(not set)" return T("not_set")
} }
return maskKey(s) return maskKey(s)
} }

View File

@@ -57,6 +57,9 @@ func (m dashboardModel) fetchData() tea.Msg {
func (m dashboardModel) Update(msg tea.Msg) (dashboardModel, tea.Cmd) { func (m dashboardModel) Update(msg tea.Msg) (dashboardModel, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case localeChangedMsg:
// Re-fetch data to re-render with new locale
return m, m.fetchData
case dashboardDataMsg: case dashboardDataMsg:
if msg.err != nil { if msg.err != nil {
m.err = msg.err m.err = msg.err
@@ -97,7 +100,7 @@ func (m *dashboardModel) SetSize(w, h int) {
func (m dashboardModel) View() string { func (m dashboardModel) View() string {
if !m.ready { if !m.ready {
return "Loading..." return T("loading")
} }
return m.viewport.View() 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 { func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []map[string]any, apiKeys []string) string {
var sb strings.Builder var sb strings.Builder
sb.WriteString(titleStyle.Render("📊 Dashboard")) sb.WriteString(titleStyle.Render(T("dashboard_title")))
sb.WriteString("\n") sb.WriteString("\n")
sb.WriteString(helpStyle.Render(" [r] refresh • [↑↓] scroll")) sb.WriteString(helpStyle.Render(T("dashboard_help")))
sb.WriteString("\n\n") sb.WriteString("\n\n")
// ━━━ Connection Status ━━━ // ━━━ Connection Status ━━━
port := 0.0
if cfg != nil {
port = getFloat(cfg, "port")
}
connStyle := lipgloss.NewStyle().Bold(true).Foreground(colorSuccess) connStyle := lipgloss.NewStyle().Bold(true).Foreground(colorSuccess)
sb.WriteString(connStyle.Render("● 已连接")) sb.WriteString(connStyle.Render(T("connected")))
sb.WriteString(fmt.Sprintf(" http://127.0.0.1:%.0f", port)) sb.WriteString(fmt.Sprintf(" %s", m.client.baseURL))
sb.WriteString("\n\n") sb.WriteString("\n\n")
// ━━━ Stats Cards ━━━ // ━━━ Stats Cards ━━━
@@ -141,7 +140,7 @@ func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []m
card1 := cardStyle.Render(fmt.Sprintf( card1 := cardStyle.Render(fmt.Sprintf(
"%s\n%s", "%s\n%s",
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("111")).Render(fmt.Sprintf("🔑 %d", keyCount)), 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 // Card 2: Auth Files
@@ -155,7 +154,7 @@ func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []m
card2 := cardStyle.Render(fmt.Sprintf( card2 := cardStyle.Render(fmt.Sprintf(
"%s\n%s", "%s\n%s",
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("76")).Render(fmt.Sprintf("📄 %d", authCount)), 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 // Card 3: Total Requests
@@ -174,7 +173,7 @@ func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []m
card3 := cardStyle.Render(fmt.Sprintf( card3 := cardStyle.Render(fmt.Sprintf(
"%s\n%s", "%s\n%s",
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")).Render(fmt.Sprintf("📈 %d", totalReqs)), 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 // Card 4: Total Tokens
@@ -182,14 +181,14 @@ func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []m
card4 := cardStyle.Render(fmt.Sprintf( card4 := cardStyle.Render(fmt.Sprintf(
"%s\n%s", "%s\n%s",
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("170")).Render(fmt.Sprintf("🔤 %s", tokenStr)), 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(lipgloss.JoinHorizontal(lipgloss.Top, card1, " ", card2, " ", card3, " ", card4))
sb.WriteString("\n\n") sb.WriteString("\n\n")
// ━━━ Current Config ━━━ // ━━━ 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("\n")
sb.WriteString(strings.Repeat("─", minInt(m.width, 60))) sb.WriteString(strings.Repeat("─", minInt(m.width, 60)))
sb.WriteString("\n") sb.WriteString("\n")
@@ -210,16 +209,16 @@ func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []m
label string label string
value string value string
}{ }{
{"启用调试模式", boolEmoji(debug)}, {T("debug_mode"), boolEmoji(debug)},
{"启用使用统计", boolEmoji(usageEnabled)}, {T("usage_stats"), boolEmoji(usageEnabled)},
{"启用日志记录到文件", boolEmoji(loggingToFile)}, {T("log_to_file"), boolEmoji(loggingToFile)},
{"重试次数", fmt.Sprintf("%.0f", retry)}, {T("retry_count"), fmt.Sprintf("%.0f", retry)},
} }
if proxyURL != "" { if proxyURL != "" {
configItems = append(configItems, struct { configItems = append(configItems, struct {
label string label string
value string value string
}{"代理 URL", proxyURL}) }{T("proxy_url"), proxyURL})
} }
// Render config items as a compact row // 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", sb.WriteString(fmt.Sprintf(" %s %s\n",
labelStyle.Render("路由策略:"), labelStyle.Render(T("routing_strategy")+":"),
valueStyle.Render(strategy))) valueStyle.Render(strategy)))
} }
@@ -247,12 +246,12 @@ func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []m
if usage != nil { if usage != nil {
if usageMap, ok := usage["usage"].(map[string]any); ok { if usageMap, ok := usage["usage"].(map[string]any); ok {
if apis, ok := usageMap["apis"].(map[string]any); ok && len(apis) > 0 { 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("\n")
sb.WriteString(strings.Repeat("─", minInt(m.width, 60))) sb.WriteString(strings.Repeat("─", minInt(m.width, 60)))
sb.WriteString("\n") 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(tableHeaderStyle.Render(header))
sb.WriteString("\n") sb.WriteString("\n")
@@ -315,9 +314,9 @@ func getBool(m map[string]any, key string) bool {
func boolEmoji(b bool) string { func boolEmoji(b bool) string {
if b { if b {
return "是 ✓" return T("bool_yes")
} }
return "否" return T("bool_no")
} }
func formatLargeNumber(n int64) string { func formatLargeNumber(n int64) string {

350
internal/tui/i18n.go Normal file
View File

@@ -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...",
}

View File

@@ -4,19 +4,36 @@ import (
"fmt" "fmt"
"strings" "strings"
"github.com/atotto/clipboard"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea" 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 { type keysTabModel struct {
client *Client client *Client
viewport viewport.Model 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 err error
width int width int
height int height int
ready bool 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 { type keysDataMsg struct {
@@ -29,9 +46,19 @@ type keysDataMsg struct {
err error err error
} }
type keyActionMsg struct {
action string
err error
}
func newKeysTabModel(client *Client) keysTabModel { func newKeysTabModel(client *Client) keysTabModel {
ti := textinput.New()
ti.CharLimit = 512
ti.Prompt = " Key: "
return keysTabModel{ return keysTabModel{
client: client, client: client,
confirm: -1,
editInput: ti,
} }
} }
@@ -41,45 +68,186 @@ func (m keysTabModel) Init() tea.Cmd {
func (m keysTabModel) fetchKeys() tea.Msg { func (m keysTabModel) fetchKeys() tea.Msg {
result := keysDataMsg{} result := keysDataMsg{}
apiKeys, err := m.client.GetAPIKeys() apiKeys, err := m.client.GetAPIKeys()
if err != nil { if err != nil {
result.err = err result.err = err
return result return result
} }
result.apiKeys = apiKeys result.apiKeys = apiKeys
// Fetch all key types, ignoring individual errors (they may not be configured)
result.gemini, _ = m.client.GetGeminiKeys() result.gemini, _ = m.client.GetGeminiKeys()
result.claude, _ = m.client.GetClaudeKeys() result.claude, _ = m.client.GetClaudeKeys()
result.codex, _ = m.client.GetCodexKeys() result.codex, _ = m.client.GetCodexKeys()
result.vertex, _ = m.client.GetVertexKeys() result.vertex, _ = m.client.GetVertexKeys()
result.openai, _ = m.client.GetOpenAICompat() result.openai, _ = m.client.GetOpenAICompat()
return result return result
} }
func (m keysTabModel) Update(msg tea.Msg) (keysTabModel, tea.Cmd) { func (m keysTabModel) Update(msg tea.Msg) (keysTabModel, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case localeChangedMsg:
m.viewport.SetContent(m.renderContent())
return m, nil
case keysDataMsg: case keysDataMsg:
if msg.err != nil { if msg.err != nil {
m.err = msg.err m.err = msg.err
m.content = errorStyle.Render("⚠ Error: " + msg.err.Error())
} else { } else {
m.err = nil 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 return m, nil
case tea.KeyMsg: case keyActionMsg:
if msg.String() == "r" { if msg.err != nil {
return m, m.fetchKeys 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 var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg) m.viewport, cmd = m.viewport.Update(msg)
return m, cmd return m, cmd
} }
}
var cmd tea.Cmd var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg) m.viewport, cmd = m.viewport.Update(msg)
@@ -89,9 +257,10 @@ func (m keysTabModel) Update(msg tea.Msg) (keysTabModel, tea.Cmd) {
func (m *keysTabModel) SetSize(w, h int) { func (m *keysTabModel) SetSize(w, h int) {
m.width = w m.width = w
m.height = h m.height = h
m.editInput.Width = w - 16
if !m.ready { if !m.ready {
m.viewport = viewport.New(w, h) m.viewport = viewport.New(w, h)
m.viewport.SetContent(m.content) m.viewport.SetContent(m.renderContent())
m.ready = true m.ready = true
} else { } else {
m.viewport.Width = w m.viewport.Width = w
@@ -101,40 +270,83 @@ func (m *keysTabModel) SetSize(w, h int) {
func (m keysTabModel) View() string { func (m keysTabModel) View() string {
if !m.ready { if !m.ready {
return "Loading..." return T("loading")
} }
return m.viewport.View() return m.viewport.View()
} }
func (m keysTabModel) renderKeys(data keysDataMsg) string { func (m keysTabModel) renderContent() string {
var sb strings.Builder var sb strings.Builder
sb.WriteString(titleStyle.Render("🔐 API Keys")) sb.WriteString(titleStyle.Render(T("keys_title")))
sb.WriteString("\n\n") sb.WriteString("\n")
sb.WriteString(helpStyle.Render(T("keys_help")))
// API Keys (access keys) sb.WriteString("\n")
renderSection(&sb, "Access API Keys", len(data.apiKeys)) sb.WriteString(strings.Repeat("─", m.width))
for i, key := range data.apiKeys {
sb.WriteString(fmt.Sprintf(" %d. %s\n", i+1, maskKey(key)))
}
sb.WriteString("\n") sb.WriteString("\n")
// Gemini Keys if m.err != nil {
renderProviderKeys(&sb, "Gemini API Keys", data.gemini) sb.WriteString(errorStyle.Render(T("error_prefix") + m.err.Error()))
sb.WriteString("\n")
return sb.String()
}
// Claude Keys // ━━━ Access API Keys (interactive) ━━━
renderProviderKeys(&sb, "Claude API Keys", data.claude) sb.WriteString(tableHeaderStyle.Render(fmt.Sprintf(" %s (%d)", T("access_keys"), len(m.keys))))
sb.WriteString("\n")
// Codex Keys if len(m.keys) == 0 {
renderProviderKeys(&sb, "Codex API Keys", data.codex) sb.WriteString(subtitleStyle.Render(T("no_keys")))
sb.WriteString("\n")
}
// Vertex Keys for i, key := range m.keys {
renderProviderKeys(&sb, "Vertex API Keys", data.vertex) cursor := " "
rowStyle := lipgloss.NewStyle()
if i == m.cursor {
cursor = "▸ "
rowStyle = lipgloss.NewStyle().Bold(true)
}
// OpenAI Compatibility row := fmt.Sprintf("%s%d. %s", cursor, i+1, maskKey(key))
if len(data.openai) > 0 { sb.WriteString(rowStyle.Render(row))
renderSection(&sb, "OpenAI Compatibility", len(data.openai)) sb.WriteString("\n")
for i, entry := range data.openai {
// 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") name := getString(entry, "name")
baseURL := getString(entry, "base-url") baseURL := getString(entry, "base-url")
prefix := getString(entry, "prefix") prefix := getString(entry, "prefix")
@@ -150,7 +362,10 @@ func (m keysTabModel) renderKeys(data keysDataMsg) string {
sb.WriteString("\n") 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() return sb.String()
} }

View File

@@ -47,6 +47,9 @@ func (m logsTabModel) waitForLog() tea.Msg {
func (m logsTabModel) Update(msg tea.Msg) (logsTabModel, tea.Cmd) { func (m logsTabModel) Update(msg tea.Msg) (logsTabModel, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case localeChangedMsg:
m.viewport.SetContent(m.renderLogs())
return m, nil
case logLineMsg: case logLineMsg:
m.lines = append(m.lines, string(msg)) m.lines = append(m.lines, string(msg))
if len(m.lines) > m.maxLines { if len(m.lines) > m.maxLines {
@@ -122,7 +125,7 @@ func (m *logsTabModel) SetSize(w, h int) {
func (m logsTabModel) View() string { func (m logsTabModel) View() string {
if !m.ready { if !m.ready {
return "Loading logs..." return T("loading")
} }
return m.viewport.View() return m.viewport.View()
} }
@@ -130,26 +133,26 @@ func (m logsTabModel) View() string {
func (m logsTabModel) renderLogs() string { func (m logsTabModel) renderLogs() string {
var sb strings.Builder var sb strings.Builder
scrollStatus := successStyle.Render("● AUTO-SCROLL") scrollStatus := successStyle.Render(T("logs_auto_scroll"))
if !m.autoScroll { if !m.autoScroll {
scrollStatus = warningStyle.Render("○ PAUSED") scrollStatus = warningStyle.Render(T("logs_paused"))
} }
filterLabel := "ALL" filterLabel := "ALL"
if m.filter != "" { if m.filter != "" {
filterLabel = strings.ToUpper(m.filter) + "+" filterLabel = strings.ToUpper(m.filter) + "+"
} }
header := fmt.Sprintf(" 📋 Logs %s Filter: %s Lines: %d", header := fmt.Sprintf(" %s %s %s: %s %s: %d",
scrollStatus, filterLabel, len(m.lines)) T("logs_title"), scrollStatus, T("logs_filter"), filterLabel, T("logs_lines"), len(m.lines))
sb.WriteString(titleStyle.Render(header)) sb.WriteString(titleStyle.Render(header))
sb.WriteString("\n") 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("\n")
sb.WriteString(strings.Repeat("─", m.width)) sb.WriteString(strings.Repeat("─", m.width))
sb.WriteString("\n") sb.WriteString("\n")
if len(m.lines) == 0 { if len(m.lines) == 0 {
sb.WriteString(subtitleStyle.Render("\n Waiting for log output...")) sb.WriteString(subtitleStyle.Render(T("logs_waiting")))
return sb.String() return sb.String()
} }

View File

@@ -93,6 +93,9 @@ func (m oauthTabModel) Init() tea.Cmd {
func (m oauthTabModel) Update(msg tea.Msg) (oauthTabModel, tea.Cmd) { func (m oauthTabModel) Update(msg tea.Msg) (oauthTabModel, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case localeChangedMsg:
m.viewport.SetContent(m.renderContent())
return m, nil
case oauthStartMsg: case oauthStartMsg:
if msg.err != nil { if msg.err != nil {
m.state = oauthError m.state = oauthError
@@ -133,9 +136,9 @@ func (m oauthTabModel) Update(msg tea.Msg) (oauthTabModel, tea.Cmd) {
case oauthCallbackSubmitMsg: case oauthCallbackSubmitMsg:
if msg.err != nil { if msg.err != nil {
m.message = errorStyle.Render("✗ 提交回调失败: " + msg.err.Error()) m.message = errorStyle.Render(T("oauth_submit_fail") + ": " + msg.err.Error())
} else { } else {
m.message = successStyle.Render("✓ 回调已提交,等待处理...") m.message = successStyle.Render(T("oauth_submit_ok"))
} }
m.viewport.SetContent(m.renderContent()) m.viewport.SetContent(m.renderContent())
return m, nil return m, nil
@@ -151,7 +154,7 @@ func (m oauthTabModel) Update(msg tea.Msg) (oauthTabModel, tea.Cmd) {
} }
m.inputActive = false m.inputActive = false
m.callbackInput.Blur() m.callbackInput.Blur()
m.message = warningStyle.Render("⏳ 提交回调中...") m.message = warningStyle.Render(T("oauth_submitting"))
m.viewport.SetContent(m.renderContent()) m.viewport.SetContent(m.renderContent())
return m, m.submitCallback(callbackURL) return m, m.submitCallback(callbackURL)
case "esc": case "esc":
@@ -217,7 +220,7 @@ func (m oauthTabModel) Update(msg tea.Msg) (oauthTabModel, tea.Cmd) {
if m.cursor >= 0 && m.cursor < len(oauthProviders) { if m.cursor >= 0 && m.cursor < len(oauthProviders) {
provider := oauthProviders[m.cursor] provider := oauthProviders[m.cursor]
m.state = oauthPending 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()) m.viewport.SetContent(m.renderContent())
return m, m.startOAuth(provider) return m, m.startOAuth(provider)
} }
@@ -307,7 +310,7 @@ func (m oauthTabModel) pollOAuthStatus(state string) tea.Cmd {
deadline := time.Now().Add(5 * time.Minute) deadline := time.Now().Add(5 * time.Minute)
for { for {
if time.Now().After(deadline) { 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) time.Sleep(2 * time.Second)
@@ -321,19 +324,19 @@ func (m oauthTabModel) pollOAuthStatus(state string) tea.Cmd {
case "ok": case "ok":
return oauthPollMsg{ return oauthPollMsg{
done: true, done: true,
message: "认证成功! 请刷新 Auth Files 标签查看新凭证。", message: T("oauth_success"),
} }
case "error": case "error":
return oauthPollMsg{ return oauthPollMsg{
done: false, done: false,
err: fmt.Errorf("认证失败: %s", errMsg), err: fmt.Errorf("%s: %s", T("oauth_failed"), errMsg),
} }
case "wait": case "wait":
continue continue
default: default:
return oauthPollMsg{ return oauthPollMsg{
done: true, done: true,
message: "认证流程已完成。", message: T("oauth_completed"),
} }
} }
} }
@@ -356,7 +359,7 @@ func (m *oauthTabModel) SetSize(w, h int) {
func (m oauthTabModel) View() string { func (m oauthTabModel) View() string {
if !m.ready { if !m.ready {
return "Loading..." return T("loading")
} }
return m.viewport.View() return m.viewport.View()
} }
@@ -364,7 +367,7 @@ func (m oauthTabModel) View() string {
func (m oauthTabModel) renderContent() string { func (m oauthTabModel) renderContent() string {
var sb strings.Builder var sb strings.Builder
sb.WriteString(titleStyle.Render("🔐 OAuth 登录")) sb.WriteString(titleStyle.Render(T("oauth_title")))
sb.WriteString("\n\n") sb.WriteString("\n\n")
if m.message != "" { if m.message != "" {
@@ -379,11 +382,11 @@ func (m oauthTabModel) renderContent() string {
} }
if m.state == oauthPending { if m.state == oauthPending {
sb.WriteString(helpStyle.Render(" Press [Esc] to cancel")) sb.WriteString(helpStyle.Render(T("oauth_press_esc")))
return sb.String() return sb.String()
} }
sb.WriteString(helpStyle.Render(" 选择提供商并按 [Enter] 开始 OAuth 登录:")) sb.WriteString(helpStyle.Render(T("oauth_select")))
sb.WriteString("\n\n") sb.WriteString("\n\n")
for i, p := range oauthProviders { for i, p := range oauthProviders {
@@ -404,7 +407,7 @@ func (m oauthTabModel) renderContent() string {
} }
sb.WriteString("\n") sb.WriteString("\n")
sb.WriteString(helpStyle.Render(" [↑↓/jk] 导航 • [Enter] 登录 • [Esc] 清除状态")) sb.WriteString(helpStyle.Render(T("oauth_help")))
return sb.String() return sb.String()
} }
@@ -417,7 +420,7 @@ func (m oauthTabModel) renderRemoteMode() string {
sb.WriteString("\n\n") sb.WriteString("\n\n")
// Auth URL section // 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") sb.WriteString("\n")
// Wrap URL to fit terminal width // Wrap URL to fit terminal width
@@ -432,23 +435,23 @@ func (m oauthTabModel) renderRemoteMode() string {
} }
sb.WriteString("\n") sb.WriteString("\n")
sb.WriteString(helpStyle.Render(" 远程浏览器模式:在浏览器中打开上述链接完成授权后,将回调 URL 粘贴到下方。")) sb.WriteString(helpStyle.Render(T("oauth_remote_hint")))
sb.WriteString("\n\n") sb.WriteString("\n\n")
// Callback URL input // 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") sb.WriteString("\n")
if m.inputActive { if m.inputActive {
sb.WriteString(m.callbackInput.View()) sb.WriteString(m.callbackInput.View())
sb.WriteString("\n") sb.WriteString("\n")
sb.WriteString(helpStyle.Render(" Enter: 提交 • Esc: 取消输入")) sb.WriteString(helpStyle.Render(" " + T("enter_submit") + " • " + T("esc_cancel")))
} else { } else {
sb.WriteString(helpStyle.Render(" 按 [c] 输入回调 URL • [Esc] 返回")) sb.WriteString(helpStyle.Render(T("oauth_press_c")))
} }
sb.WriteString("\n\n") sb.WriteString("\n\n")
sb.WriteString(warningStyle.Render(" 等待认证中...")) sb.WriteString(warningStyle.Render(T("oauth_waiting")))
return sb.String() return sb.String()
} }

View File

@@ -43,6 +43,9 @@ func (m usageTabModel) fetchData() tea.Msg {
func (m usageTabModel) Update(msg tea.Msg) (usageTabModel, tea.Cmd) { func (m usageTabModel) Update(msg tea.Msg) (usageTabModel, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case localeChangedMsg:
m.viewport.SetContent(m.renderContent())
return m, nil
case usageDataMsg: case usageDataMsg:
if msg.err != nil { if msg.err != nil {
m.err = msg.err m.err = msg.err
@@ -82,7 +85,7 @@ func (m *usageTabModel) SetSize(w, h int) {
func (m usageTabModel) View() string { func (m usageTabModel) View() string {
if !m.ready { if !m.ready {
return "Loading..." return T("loading")
} }
return m.viewport.View() return m.viewport.View()
} }
@@ -90,9 +93,9 @@ func (m usageTabModel) View() string {
func (m usageTabModel) renderContent() string { func (m usageTabModel) renderContent() string {
var sb strings.Builder var sb strings.Builder
sb.WriteString(titleStyle.Render("📈 使用统计")) sb.WriteString(titleStyle.Render(T("usage_title")))
sb.WriteString("\n") sb.WriteString("\n")
sb.WriteString(helpStyle.Render(" [r] refresh • [↑↓] scroll")) sb.WriteString(helpStyle.Render(T("usage_help")))
sb.WriteString("\n\n") sb.WriteString("\n\n")
if m.err != nil { if m.err != nil {
@@ -102,14 +105,14 @@ func (m usageTabModel) renderContent() string {
} }
if m.usage == nil { if m.usage == nil {
sb.WriteString(subtitleStyle.Render(" Usage data not available")) sb.WriteString(subtitleStyle.Render(T("usage_no_data")))
sb.WriteString("\n") sb.WriteString("\n")
return sb.String() return sb.String()
} }
usageMap, _ := m.usage["usage"].(map[string]any) usageMap, _ := m.usage["usage"].(map[string]any)
if usageMap == nil { if usageMap == nil {
sb.WriteString(subtitleStyle.Render(" No usage data")) sb.WriteString(subtitleStyle.Render(T("usage_no_data")))
sb.WriteString("\n") sb.WriteString("\n")
return sb.String() return sb.String()
} }
@@ -137,17 +140,17 @@ func (m usageTabModel) renderContent() string {
// Total Requests // Total Requests
card1 := cardStyle.Copy().BorderForeground(lipgloss.Color("111")).Render(fmt.Sprintf( card1 := cardStyle.Copy().BorderForeground(lipgloss.Color("111")).Render(fmt.Sprintf(
"%s\n%s\n%s", "%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().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 // Total Tokens
card2 := cardStyle.Copy().BorderForeground(lipgloss.Color("214")).Render(fmt.Sprintf( card2 := cardStyle.Copy().BorderForeground(lipgloss.Color("214")).Render(fmt.Sprintf(
"%s\n%s\n%s", "%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().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 // RPM
@@ -159,9 +162,9 @@ func (m usageTabModel) renderContent() string {
} }
card3 := cardStyle.Copy().BorderForeground(lipgloss.Color("76")).Render(fmt.Sprintf( card3 := cardStyle.Copy().BorderForeground(lipgloss.Color("76")).Render(fmt.Sprintf(
"%s\n%s\n%s", "%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().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 // TPM
@@ -173,9 +176,9 @@ func (m usageTabModel) renderContent() string {
} }
card4 := cardStyle.Copy().BorderForeground(lipgloss.Color("170")).Render(fmt.Sprintf( card4 := cardStyle.Copy().BorderForeground(lipgloss.Color("170")).Render(fmt.Sprintf(
"%s\n%s\n%s", "%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().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)) 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) ━━━ // ━━━ Requests by Hour (ASCII bar chart) ━━━
if rByH, ok := usageMap["requests_by_hour"].(map[string]any); ok && len(rByH) > 0 { 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("\n")
sb.WriteString(strings.Repeat("─", minInt(m.width, 60))) sb.WriteString(strings.Repeat("─", minInt(m.width, 60)))
sb.WriteString("\n") sb.WriteString("\n")
@@ -193,7 +196,7 @@ func (m usageTabModel) renderContent() string {
// ━━━ Tokens by Hour ━━━ // ━━━ Tokens by Hour ━━━
if tByH, ok := usageMap["tokens_by_hour"].(map[string]any); ok && len(tByH) > 0 { 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("\n")
sb.WriteString(strings.Repeat("─", minInt(m.width, 60))) sb.WriteString(strings.Repeat("─", minInt(m.width, 60)))
sb.WriteString("\n") sb.WriteString("\n")
@@ -203,7 +206,7 @@ func (m usageTabModel) renderContent() string {
// ━━━ Requests by Day ━━━ // ━━━ Requests by Day ━━━
if rByD, ok := usageMap["requests_by_day"].(map[string]any); ok && len(rByD) > 0 { 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("\n")
sb.WriteString(strings.Repeat("─", minInt(m.width, 60))) sb.WriteString(strings.Repeat("─", minInt(m.width, 60)))
sb.WriteString("\n") sb.WriteString("\n")
@@ -213,12 +216,12 @@ func (m usageTabModel) renderContent() string {
// ━━━ API Detail Stats ━━━ // ━━━ API Detail Stats ━━━
if apis, ok := usageMap["apis"].(map[string]any); ok && len(apis) > 0 { 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("\n")
sb.WriteString(strings.Repeat("─", minInt(m.width, 80))) sb.WriteString(strings.Repeat("─", minInt(m.width, 80)))
sb.WriteString("\n") 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(tableHeaderStyle.Render(header))
sb.WriteString("\n") sb.WriteString("\n")
@@ -289,16 +292,16 @@ func (m usageTabModel) renderTokenBreakdown(modelStats map[string]any) string {
parts := []string{} parts := []string{}
if inputTotal > 0 { 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 { 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 { 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 { 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", return fmt.Sprintf(" │ %s\n",