mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-19 12:50:51 +08:00
feat(tui): add i18n
This commit is contained in:
2
go.mod
2
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 == "<nil>" {
|
||||
if field.editable {
|
||||
val = "(not set)"
|
||||
val = T("not_set")
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
350
internal/tui/i18n.go
Normal file
350
internal/tui/i18n.go
Normal 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...",
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user