feat: 发布 v1.0.0-beta 版本

- 添加 ASCII 艺术 Logo 带渐变效果
- 改造输入框使用 ::: 紫色分隔符
- 改造信息栏合并显示语言/模型/记录数
- 添加 Spinner 翻译状态动画
- 优化翻译卡片样式
- 版本号三方同步规则
This commit is contained in:
2026-04-07 07:12:00 +08:00
parent 98f2c69151
commit b04092fd68
6 changed files with 200 additions and 73 deletions

View File

@@ -3,19 +3,27 @@ package tui
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"charm.land/bubbles/v2/spinner"
"charm.land/bubbles/v2/textarea"
"charm.land/bubbles/v2/viewport"
"charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/titor/fanyi/internal/config"
"github.com/titor/fanyi/internal/content"
"github.com/titor/fanyi/internal/translator"
)
var supportedLangs = []string{"zh-CN", "en-US", "ja", "ko", "zh-TW", "es", "fr", "de"}
var logoPattern = "l_ _ _____ _____ " + "\n" +
"( \\/ ( _ ( _ ) " + "\n" +
" \\ / )(_)( )(_)( " + "\n" +
" (__)(_____(_____((() [v" + content.Version + "]"
type translateMsg struct {
result string
tokens int
@@ -29,6 +37,7 @@ type model struct {
messages []ChatMessage
input textarea.Model
viewport viewport.Model
spinner spinner.Model
keys KeyMap
targetLang string
@@ -45,25 +54,31 @@ func NewApp(cfg *config.Config, t *translator.Translator) *tea.Program {
keys := NewKeyMap()
ta := textarea.New()
ta.Placeholder = "输入要翻译的文本... (Ctrl+J 换行)"
ta.Placeholder = "在这里输入要翻译的内容。Enter 翻译,Ctrl+J 启用换行"
ta.Focus()
ta.Prompt = ""
ta.ShowLineNumbers = false
ta.SetWidth(60)
ta.SetHeight(5)
ta.SetStyles(textarea.DefaultStyles(false))
ta.SetStyles(textarea.DefaultStyles(true))
ta.KeyMap.InsertNewline.SetKeys("ctrl+j")
ta.KeyMap.InsertNewline.SetEnabled(true)
vp := viewport.New(viewport.WithWidth(50), viewport.WithHeight(20))
vp.SetContent("")
sp := spinner.New()
sp.Spinner = spinner.MiniDot
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#8B5CF6"))
return tea.NewProgram(model{
config: cfg,
translator: t,
messages: make([]ChatMessage, 0),
input: ta,
viewport: vp,
spinner: sp,
keys: keys,
targetLang: getDefaultLang(cfg),
})
@@ -77,7 +92,7 @@ func getDefaultLang(cfg *config.Config) string {
}
func (m model) Init() tea.Cmd {
return nil
return m.spinner.Tick
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -135,6 +150,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.viewport, cmd = m.viewport.Update(msg)
cmds = append(cmds, cmd)
m.spinner, cmd = m.spinner.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
@@ -213,34 +231,24 @@ func (m *model) updateViewportContent() {
}
func (m *model) renderTranslationCard(msg ChatMessage) string {
metaContent := lipgloss.JoinHorizontal(
lipgloss.Left,
CardMetaStyle.Render(fmt.Sprintf("Tokens: %d", msg.Tokens)),
CardMetaSeparatorStyle,
CardMetaStyle.Render(fmt.Sprintf("耗时: %s", msg.Timestamp.Format("15:04:05"))),
CardMetaSeparatorStyle,
CardMetaStyle.Render(fmt.Sprintf("模型: %s", msg.Model)),
)
contentWidth := m.viewport.Width() - 2
metaBlock := lipgloss.NewStyle().
BorderStyle(lipgloss.Border{
Top: "─",
Bottom: "─",
Left: "│",
Right: "│",
}).
BorderForeground(lipgloss.Color("#374151")).
Width(m.viewport.Width() - 2).
Render(metaContent)
timeStr := msg.Timestamp.Format("15:04")
timeLabel := CardTimeStyle.Render("# " + timeStr)
inputContent := lipgloss.NewStyle().
Width(m.viewport.Width() - 2).
inputText := lipgloss.NewStyle().
Width(contentWidth).
Render(msg.Input)
inputBlock := lipgloss.NewStyle().
Background(lipgloss.Color("#1A1A1A")).
Padding(1, 3).
Width(m.viewport.Width()).
Render(inputContent)
Render(lipgloss.JoinVertical(
lipgloss.Top,
timeLabel,
inputText,
))
var outputBlock string
if msg.Error != "" {
@@ -249,6 +257,7 @@ func (m *model) renderTranslationCard(msg ChatMessage) string {
Render(msg.Error)
outputBlock = lipgloss.NewStyle().
Foreground(lipgloss.Color("#F87171")).
Padding(0, 3, 1, 3).
Width(m.viewport.Width()).
Render(outputContent)
} else {
@@ -256,16 +265,25 @@ func (m *model) renderTranslationCard(msg ChatMessage) string {
Width(m.viewport.Width() - 2).
Render(msg.Output)
outputBlock = lipgloss.NewStyle().
Padding(0, 3, 1, 3).
Width(m.viewport.Width()).
Render(outputContent)
}
footerContent := lipgloss.NewStyle().
MarginLeft(3).
Render(lipgloss.JoinHorizontal(
lipgloss.Left,
CardFooterIconStyle.Render("▣"),
CardFooterTextStyle.Render(msg.Model+" · "+strconv.Itoa(msg.Tokens)),
))
return CardStyle.Render(
lipgloss.JoinVertical(
lipgloss.Top,
metaBlock,
inputBlock,
outputBlock,
footerContent,
),
) + "\n"
}
@@ -285,19 +303,55 @@ func (m model) View() tea.View {
header := m.renderHeader()
messages := m.viewport.View()
inputArea := m.renderInputArea()
statusBar := m.renderStatusBar()
infoBar := m.renderInfoBar()
spinnerView := m.renderSpinner()
content := header + "\n" + messages + inputArea + statusBar
content := header + "\n" + messages + inputArea + infoBar + spinnerView
v := tea.NewView(content)
v.AltScreen = true
return v
}
func gradientText(text string, startColor, endColor string) string {
startR, startG, startB := parseHexColor(startColor)
endR, endG, endB := parseHexColor(endColor)
lines := strings.Split(text, "\n")
if len(lines) == 0 {
return text
}
var result []string
for _, line := range lines {
if len(line) == 0 {
result = append(result, line)
continue
}
var coloredLine string
for i, char := range line {
ratio := float64(i) / float64(len(line)-1)
r := int(float64(startR) + float64(endR-startR)*ratio)
g := int(float64(startG) + float64(endG-startG)*ratio)
b := int(float64(startB) + float64(endB-startB)*ratio)
coloredLine += fmt.Sprintf("\033[38;2;%d;%d;%dm%s\033[0m", r, g, b, string(char))
}
result = append(result, coloredLine)
}
return strings.Join(result, "\n")
}
func parseHexColor(hex string) (int, int, int) {
hex = strings.TrimPrefix(hex, "#")
r, _ := strconv.ParseInt(hex[0:2], 16, 64)
g, _ := strconv.ParseInt(hex[2:4], 16, 64)
b, _ := strconv.ParseInt(hex[4:6], 16, 64)
return int(r), int(g), int(b)
}
func (m model) renderHeader() string {
title := lipgloss.NewStyle().
Foreground(lipgloss.Color("#8B5CF6")).
Bold(true).
Render("✦ YOYO 翻译")
title := gradientText(logoPattern, "#8B5CF6", "#EC4899")
width := m.width - 4
if width < 20 {
@@ -310,57 +364,50 @@ func (m model) renderHeader() string {
return lipgloss.NewStyle().
Width(width).
Render(title + strings.Repeat(" ", width-len(title)-len(right)-1) + right)
Render(title + strings.Repeat(" ", width-29-len(right)-1) + right)
}
func (m model) renderInputArea() string {
inputView := m.input.View()
container := lipgloss.NewStyle().
Width(m.input.Width() + 1).
BorderStyle(lipgloss.Border{
Top: "─",
Bottom: "─",
Left: "│",
Right: "│",
}).
BorderForeground(lipgloss.Color("#60A5FA"))
return "\n" + container.Render(inputView) + "\n"
separator := lipgloss.NewStyle().
Foreground(lipgloss.Color("#8B5CF6")).
Render(":::")
return "\n" + separator + "\n" + inputView + "\n"
}
func (m model) renderStatusBar() string {
langInfo := "目标: " + m.targetLang
modelInfo := "模型: " + getModelName(m.config)
tokensInfo := "Tokens: -"
if len(m.messages) > 0 {
lastMsg := m.messages[len(m.messages)-1]
if lastMsg.Tokens > 0 {
tokensInfo = fmt.Sprintf("Tokens: %d", lastMsg.Tokens)
func (m model) renderInfoBar() string {
var recordCount int
if m.translator != nil && m.translator.GetCache() != nil {
stats, _ := m.translator.GetCache().Stats(context.Background())
if stats != nil {
recordCount = stats.TotalRecords
}
}
statusDot := StatusDotStyle.Render("●")
var countInfo string
if recordCount == 0 {
countInfo = "暂无记录"
} else {
countInfo = fmt.Sprintf("%d条", recordCount)
}
sep := lipgloss.NewStyle().Foreground(lipgloss.Color("#8B5CF6")).Render(" ")
separator := lipgloss.NewStyle().Foreground(lipgloss.Color("#8B5CF6")).Render(":::")
lang := lipgloss.NewStyle().Foreground(lipgloss.Color("#F87171")).Render(m.targetLang)
model := lipgloss.NewStyle().Foreground(lipgloss.Color("#FAFAFA")).Render(getModelName(m.config))
count := lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280")).Render(countInfo)
result := separator + sep + lang + sep + model + sep + count
if m.loading {
statusDot = LoadingStyle.Render("○")
result += sep + m.spinner.View() + " 翻译中..."
}
sep := StatusItemStyle.Render(" │ ")
return result + "\n"
}
width := m.width - 4
if width < 30 {
width = 60
}
status := StatusItemStyle.Render(langInfo) +
sep + StatusItemStyle.Render(modelInfo) +
sep + StatusItemStyle.Render(tokensInfo) +
sep + statusDot + " " + StatusValueStyle.Render(m.getStatusText())
return lipgloss.NewStyle().
Width(width).
Background(lipgloss.Color("#1F2937")).
Render(" " + status)
func (m model) renderSpinner() string {
return ""
}
func (m model) getStatusText() string {

View File

@@ -51,7 +51,7 @@ var (
Foreground(lipgloss.Color("#34D399"))
CardStyle = lipgloss.NewStyle().
MarginBottom(5)
MarginBottom(1)
CardMetaStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#6B7280")).
@@ -68,4 +68,17 @@ var (
CardOutputStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
CardTimeStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#6B7280")).
MarginBottom(1)
CardFooterIconStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#8B5CF6")).
AlignVertical(lipgloss.Center)
CardFooterTextStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#6B7280")).
MarginLeft(2).
AlignVertical(lipgloss.Center)
)