# 记忆纠正 (memory.md) > 本文档记录开发过程中的重要定义、踩过的坑和经验总结,用于AI内部知识纠正。 ## 使用说明 - 定期更新,特别是遇到问题后 - 按类别组织,便于查找 - 包含具体示例和解决方案 ## 技术决策记录 ### Go语言选择 **决策**: 使用Go语言而非Node.js或Deno **原因**: 1. Go编译为单一二进制文件,部署方便 2. 性能优秀,适合CLI工具 3. 强大的标准库支持 4. 用户愿意学习Go **影响**: 项目结构、依赖管理、开发工具链 --- ### 面向对象设计 **决策**: 在Go中实现面向对象设计模式 **模式应用**: 1. **工厂模式**: ProviderFactory创建厂商实例 2. **策略模式**: Provider接口定义不同厂商策略 3. **依赖注入**: Translator依赖Provider接口 **注意事项**: - Go中没有类,使用结构体 - 继承通过组合实现 - 多态通过接口实现 --- ## 术语定义 ### 厂商 (Provider) 指提供大模型API的服务商,如硅基流动、火山引擎等。每个厂商实现统一的`Provider`接口。 ### 配置 (Config) 全局配置对象,包含API密钥、模型选择、超时设置等。使用YAML格式。 ### 核心翻译器 (Translator) 负责协调配置、厂商和Prompt管理,执行翻译任务的核心类。 ## 踩过的坑 ### 环境变量加载问题 **问题**: 配置文件中的环境变量(如`${API_KEY}`)没有正确解析 **原因**: 没有实现环境变量替换功能 **解决方案**: 1. 使用`os.Getenv`读取环境变量 2. 在配置加载时进行字符串替换 3. 添加环境变量验证 **预防措施**: - 在配置加载后验证所有必需的环境变量 - 提供清晰的错误信息 --- ### 厂商API差异 **问题**: 不同厂商的API格式差异较大 **原因**: 每个厂商有自己的请求/响应格式 **解决方案**: 1. 定义统一的`TranslateRequest`和`TranslateResponse` 2. 在每个厂商实现中进行格式转换 3. 使用适配器模式 **经验**: - 优先设计统一接口 - 将厂商特定逻辑封装在实现内部 - 提供原始响应用于调试 --- ### 版本号管理混乱 **问题**: 版本号递增规则不明确 **原因**: 没有明确的版本管理规范 **解决方案**: 1. 采用语义化版本:主版本.次版本.修订版本 2. 第三位限制为00-99 3. 建立更新流程 **规范**: - 小修复:修订版本+1 - 新功能:次版本+1,修订版本重置为00 - 重大变更:主版本+1,次版本和修订版本重置 --- ### 环境变量加载问题 **问题**: 配置文件中的环境变量没有正确加载 **原因**: Go程序不会自动加载.env文件,需要使用第三方库 **解决方案**: 1. 使用`github.com/joho/godotenv`包 2. 在程序启动时调用`godotenv.Load()` 3. 将.env文件添加到.gitignore **代码示例**: ```go import "github.com/joho/godotenv" func main() { _ = godotenv.Load() // 加载.env文件 // 然后加载配置文件 } ``` **注意事项**: - 不要提交真实的.env文件到版本控制 - 提供.env.example模板 - 在文档中说明环境变量配置方法 --- ## 配置最佳实践 ### 安全配置 - API密钥使用环境变量 - 不提交敏感信息到版本控制 - 提供`.env.example`模板 ### 配置验证 - 启动时验证所有必需配置 - 提供有意义的错误信息 - 支持配置热重载(未来) ### 默认值策略 - 为非必需配置提供合理的默认值 - 默认值应在`config.setDefaults()`中设置 - 记录默认值的作用 --- ## 开发工作流 ### 日常开发流程 1. 从`dev`分支创建功能分支 2. 开发并测试功能 3. 更新相关文档(taolun.md、changelog.md) 4. 提交到功能分支 5. 合并到`dev`分支 6. 测试通过后合并到`main` ### 版本发布流程 1. 确保`dev`分支稳定 2. 更新版本号 3. 更新changelog.md 4. 创建版本标签 5. 合并到`main`分支 ### 文档维护 - 每次重要讨论后更新taolun.md - 每个版本更新changelog.md - 遇到问题后更新memory.md --- ## 文档管理规范 ### why.md (项目初衷文档) **用途**: 记录项目初衷、愿景、目标和个人笔记 **编辑权限**: 只能由项目所有者(用户)编辑 **位置**: 项目根目录 **内容建议**: - 项目愿景 - 核心问题 - 目标用户 - 期望功能 - 个人笔记 **注意事项**: - AI不应修改此文件 - 文件内容反映创始人的个人想法 - 可以自由格式,不强制结构 ### 文档协作规范 **taolun.md**: AI与用户共同维护的讨论记录 **changelog.md**: AI与用户共同维护的版本记录 **memory.md**: AI主导的知识纠正和经验总结 **why.md**: 用户专属的项目初衷文档 **编辑流程**: 1. 用户编辑why.md记录初衷 2. AI编辑taolun.md记录讨论 3. AI更新changelog.md记录版本 4. AI更新memory.md记录经验 --- ## 语言代码处理经验 ### 语言代码标准化 **问题**: 需要支持多种语言代码格式,但内部应使用标准格式 **解决方案**: 1. 使用BCP 47语言标签作为标准格式(如 `zh-CN`、`en-US`) 2. 实现智能解析函数 `ParseLanguageCode()` 3. 支持别名映射(如 `cn` → `zh-CN`、`en` → `en-US`) **最佳实践**: - 语言代码小写,地区代码大写(如 `zh-CN`,不是 `zh-cn`) - 提供语言名称映射用于显示(如 `zh-CN` → "中文(简体)") - 支持模糊匹配和建议功能 ### 交互式配置经验 **问题**: 命令行工具需要友好的配置界面 **解决方案**: 1. 使用 `github.com/AlecAivazis/survey/v2` 库 2. 实现分步配置流程 3. 提供默认值和确认选项 **注意事项**: - 交互式库需要终端支持 - 提供非交互式模式(如配置文件模板) - 错误处理要友好,避免程序崩溃 ### 命令行参数解析经验 **问题**: Go标准库 `flag` 包功能有限,需要支持子命令 **解决方案**: 1. 使用 `flag` 包解析选项参数 2. 手动处理子命令(如 `onboard`) 3. 提供清晰的帮助信息 **命名冲突处理**: - 避免变量名与包名冲突(如 `onboard` 变量与 `onboard` 包) - 使用后缀区分(如 `onboardFlag`) ## 配置文件管理经验 ### 开发阶段配置策略 **决策**: 开发阶段使用 `.env` + `configs/config.yaml` **原因**: 1. 简化开发环境配置 2. 符合12-factor应用原则 3. 避免过早优化 **实施**: - `.env` 文件存储API密钥等敏感信息 - `configs/config.yaml` 存储复杂配置结构 - 使用环境变量替换 `${VAR}` ### 配置文件格式选择 **决策**: 使用YAML格式 **原因**: 1. 人类可读性好 2. 支持复杂数据结构 3. Go生态支持良好 **注意事项**: - 使用 `gopkg.in/yaml.v3` 库 - 注意缩进和格式 - 提供配置验证 --- ## 管道功能实现经验 ### 管道输入检测 **问题**: 如何检测是否有管道输入? **解决方案**: 1. 使用 `os.Stdin.Stat()` 获取标准输入的状态 2. 检查 `fileInfo.Mode() & os.ModeCharDevice` 是否为0 3. 如果为0,表示是管道或文件重定向 **代码示例**: ```go func isPipeInput() bool { fileInfo, err := os.Stdin.Stat() if err != nil { return false } return (fileInfo.Mode()&os.ModeCharDevice) == 0 } ``` **注意事项**: - 在管道模式下,即使没有命令行参数也应继续执行 - 管道输入可能为空,需要处理空输入情况 - 错误信息应输出到stderr,避免污染管道输出 ### 管道输入读取 **问题**: 如何从标准输入读取所有内容? **解决方案**: 1. 使用 `bufio.Scanner` 逐行读取 2. 使用 `strings.Join()` 合并多行 3. 处理读取错误 **代码示例**: ```go func readFromStdin() (string, error) { scanner := bufio.NewScanner(os.Stdin) var lines []string for scanner.Scan() { lines = append(lines, scanner.Text()) } if err := scanner.Err(); err != nil { return "", fmt.Errorf("读取标准输入失败: %w", err) } return strings.Join(lines, "\n"), nil } ``` ### 静默模式设计 **问题**: 管道使用时,统计信息会污染输出 **解决方案**: 1. 添加 `--quiet` 和 `-q` 参数 2. 将统计信息输出到stderr 3. 静默模式下只输出翻译结果 **设计原则**: - 翻译结果始终输出到stdout - 统计信息输出到stderr - 静默模式下抑制统计信息输出 - 错误信息始终输出到stderr ### 正则表达式转义 **问题**: 特殊字符在正则表达式中需要转义 **解决方案**: 1. 使用 `regexp.QuoteMeta()` 转义特殊字符 2. 避免手动拼接正则表达式 **代码示例**: ```go // 错误的方式 pattern := regexp.MustCompile(symbol + `{21,}`) // 如果symbol是*会出错 // 正确的方式 pattern := regexp.MustCompile(regexp.QuoteMeta(symbol) + `{21,}`) ``` **特殊字符列表**: - `*`, `+`, `?`, `.`, `^`, `$`, `|`, `(`, `)`, `[`, `]`, `{`, `}` ### 输出重定向 **问题**: 如何确保管道输出不被其他信息污染? **解决方案**: 1. 翻译结果使用 `fmt.Println()` 输出到stdout 2. 统计信息使用 `fmt.Fprintf(os.Stderr, ...)` 输出到stderr 3. 错误信息也输出到stderr **最佳实践**: - 始终区分stdout和stderr的使用场景 - 为管道功能提供静默模式选项 - 确保错误信息不影响管道输出 --- ## 本地缓存实现经验 ### SQLite数据库设计 **问题**: 如何设计缓存表结构以支持高效的查询和存储? **解决方案**: 1. 使用缓存键(cache_key)作为唯一索引 2. 包含完整字段:原文、译文、语言对、模型、Prompt、用量统计 3. 添加created_at和last_used_at时间戳字段 4. 创建适当的索引提高查询性能 **表结构**: ```sql CREATE TABLE translation_cache ( id INTEGER PRIMARY KEY AUTOINCREMENT, cache_key TEXT NOT NULL UNIQUE, original_text TEXT NOT NULL, translated_text TEXT NOT NULL, from_lang TEXT NOT NULL, to_lang TEXT NOT NULL, model TEXT NOT NULL, prompt_name TEXT, prompt_content TEXT, prompt_tokens INTEGER DEFAULT 0, completion_tokens INTEGER DEFAULT 0, total_tokens INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); ``` **索引设计**: ```sql CREATE INDEX idx_cache_key ON translation_cache(cache_key); CREATE INDEX idx_original_text ON translation_cache(original_text); CREATE INDEX idx_created_at ON translation_cache(created_at); CREATE INDEX idx_last_used_at ON translation_cache(last_used_at); ``` ### 缓存键生成策略 **问题**: 如何生成唯一的缓存键,确保相同内容返回相同结果? **解决方案**: 1. 使用SHA256哈希算法 2. 哈希输入:原文+语言对(from_lang+to_lang) 3. 规范化输入:移除多余空白字符,统一语言代码格式 **代码示例**: ```go func GenerateCacheKey(originalText, fromLang, toLang string) string { // 规范化语言代码 fromLang = normalizeLanguageCode(fromLang) toLang = normalizeLanguageCode(toLang) // 规范化原文 normalizedText := normalizeText(originalText) // 生成缓存键 data := fmt.Sprintf("%s|%s|%s", normalizedText, fromLang, toLang) hash := sha256.Sum256([]byte(data)) return hex.EncodeToString(hash[:]) } ``` ### SQLite NULL值处理 **问题**: 当缓存表为空时,MIN(created_at)返回NULL,导致时间转换错误 **解决方案**: 1. 使用sql.NullString和sql.NullFloat64类型 2. 检查Valid字段判断是否为NULL 3. 只在有记录时才查询时间范围 **代码示例**: ```go var oldestStr, newestStr sql.NullString var avgTokens sql.NullFloat64 err = c.db.QueryRowContext(ctx, ` SELECT MIN(created_at), MAX(created_at), AVG(total_tokens) FROM translation_cache `).Scan(&oldestStr, &newestStr, &avgTokens) if oldestStr.Valid { stats.OldestRecord, _ = time.Parse("2006-01-02 15:04:05", oldestStr.String) } ``` ### 缓存集成策略 **问题**: 如何将缓存功能集成到现有的翻译流程中? **解决方案**: 1. 在Translator结构中添加缓存字段 2. 修改NewTranslator函数,初始化缓存实例 3. 在Translate方法中实现"先查缓存再调用API"策略 4. 翻译成功后异步保存到缓存 **集成流程**: ``` 翻译请求 → 生成缓存键 → 查询缓存 → 缓存命中? → 直接返回 ↓ 否 调用API翻译 → 保存到缓存 → 返回结果 ``` ### 异步缓存保存 **问题**: 如何避免缓存保存阻塞翻译结果返回? **解决方案**: 1. 使用goroutine异步保存缓存 2. 使用context.Background()创建新的上下文 3. 错误日志记录到标准错误输出 **代码示例**: ```go // 异步保存缓存,不阻塞翻译结果返回 go t.cache.Set(context.Background(), cacheEntry) ``` ### 组合清理策略 **问题**: 如何平衡缓存大小和缓存时效性? **解决方案**: 1. 数量限制:限制最大记录数 2. 时间过期:设置缓存过期时间 3. 组合策略:同时应用两种限制 **清理逻辑**: 1. 清理过期记录:`DELETE FROM cache WHERE last_used_at < ?` 2. 清理超出数量限制:保留最近使用的N条记录 3. 定时清理:每小时自动清理一次 ### 性能优化 **SQLite性能优化**: 1. 使用WAL模式(Write-Ahead Logging) 2. 设置适当的连接池参数 3. 使用索引提高查询性能 4. 批量操作使用事务 **Go SQLite配置**: ```go db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_synchronous=NORMAL") db.SetMaxOpenConns(1) // SQLite只支持单个写入连接 db.SetMaxIdleConns(1) ``` ### 错误处理经验 **常见错误及解决方案**: 1. **NULL值转换错误**: 使用sql.NullString类型 2. **时间解析错误**: 使用正确的时间格式字符串 3. **数据库锁定错误**: 设置适当的连接池参数 4. **权限错误**: 确保目录存在且有写入权限 ### 测试策略 **单元测试要点**: 1. 测试缓存键生成的一致性 2. 测试缓存命中和未命中情况 3. 测试清理策略的正确性 4. 测试NULL值处理 5. 测试并发访问安全性 **集成测试要点**: 1. 测试缓存与Translator的集成 2. 测试配置文件的正确加载 3. 测试缓存命令的正确执行 4. 测试错误情况的正确处理 --- ## TUI界面开发策略 ### 分模块实现策略 **决策**: TUI界面分6个小模块逐步实现,每次只做一个模块 **原因**: 1. 减少Token消耗和上下文负担 2. 便于逐步测试和验证 3. 适应free模式的限流约束 **模块列表**: 1. TUI框架搭建 - bubbletea基础App结构 2. 输入组件 - 文本输入框 3. 翻译显示区 - 结果展示、滚动 4. 状态栏/主题 - 底部状态栏、配色 5. 快捷键系统 - 操作快捷键 6. 集成翻译 - 对接Translator、加载动画 ### 技术选型 **决策**: 使用charmbracelet生态 - `bubbletea` - Elm架构的TUI框架 - `lipgloss` - 样式和主题 - `bubbles/textinput` - 输入框组件 **原因**: 1. Go生态最流行的TUI框架 2. Elm架构清晰,易于分模块实现 3. 组件化设计,便于复用 ### 文档管理规范 **决策**: 不再创建新的md文件 **原因**: 保持项目文档简洁,避免碎片化 **规则**: - 讨论内容 → `taolun.md` - 版本更新 → `changelog.md` - 经验教训 → `memory.md` - 项目初衷 → `why.md` (仅用户编辑) --- ## Bubble Tea TUI框架经验 ### 版本信息 **当前版本**: v1.3.10 **API风格**: v1版本风格(Init返回tea.Cmd) ### 基础结构 ```go type model struct { // 状态字段 } func (m model) Init() tea.Cmd { return nil // 初始化命令 } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil // 更新逻辑 } func (m model) View() string { return "视图内容" // 返回渲染内容 } func NewApp(cfg, translator) *tea.Program { return tea.NewProgram(model{...}) } ``` ### 关键点 - `tea.NewProgram()` 创建程序实例 - `program.Run()` 返回 `(model, error)` - model的字段可以是config、translator等依赖 - View方法返回string,使用lipgloss样式 ### main.go集成注意 - 版本检查(--version)需要在interactive模式检查之前 - 避免interactive模式在非TTY环境启动 - Run()需要两个返回值: `_, err := app.Run()` ### TextInput组件使用 ```go import "github.com/charmbracelet/bubbles/textinput" // model中添加字段 type model struct { textInput textinput.Model } // 初始化 ti := textinput.New() ti.Placeholder = "输入文本..." ti.Focus() // 获取焦点 ti.Prompt = "> " // 提示符 // Update中处理 m.textInput, cmd = m.textInput.Update(msg) // View中显示 m.textInput.View() ``` ### Lipgloss样式定义 ```go var ( headerStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#00D9FF")). Bold(true) resultStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#98FB98")). Background(lipgloss.Color("#0D1B2A")) helpStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#888888")) keyStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#60A5FA")) ) ``` ### 多区域View渲染 ```go func (m model) View() string { return "\n" + " " + headerStyle.Render("YOYO翻译") + "\n" + " " + divider + "\n\n" + " " + m.textInput.View() + "\n\n" + m.renderResult() + helpText } func (m model) renderResult() string { if m.result == "" { return " " + helpStyle.Render("翻译结果将显示在这里...") + "\n" } return " " + resultStyle.Render(m.result) + "\n" } ### 异步命令和消息模式 ```go // 定义自定义消息类型 type translateMsg struct { result string err error } // 异步执行函数 func (m model) doTranslate(text string) tea.Cmd { return func() tea.Msg { result, err := translate(text) if err != nil { return translateMsg{err: err} } return translateMsg{result: result} } } // Update中处理消息 func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case translateMsg: if msg.err != nil { m.errMsg = msg.err.Error() } else { m.result = msg.result } return m, nil } return m, nil } ``` ### 加载状态处理 ```go type model struct { loading bool errMsg string } // View中显示loading func (m model) View() string { if m.loading { return "正在翻译..." } if m.errMsg != "" { return "错误: " + m.errMsg } return m.result } ``` --- ## TUI界面改进知识 ### TextInput vs Textarea - **textinput**: 单行输入,适合短文本 - **textarea**: 多行输入,适合长段落 - 切换时需要调整布局和样式 ### Modal/弹出框设计 ```go type model struct { showModal bool modalType string // "help", "command", "info" } // View中渲染modal func (m model) View() string { s := "主界面..." if m.showModal { s += m.renderModal() } return s } ``` ### 斜杠命令菜单 ```go type command struct { name string desc string handler func() } var commands = []command{ {"help", "显示帮助", handleHelp}, {"clear", "清空内容", handleClear}, {"copy", "复制结果", handleCopy}, } // 模糊匹配 func matchCommand(input string) []command { // 过滤匹配的命令 } ``` ### Viewport组件 用于长文本滚动显示,配合scrollbar展示滚动位置。 ``` --- ## TUI输入框踩坑记录 (v0.8.0) ### 问题1:Ctrl+J换行后第一行被遮住 **现象**: - 按Ctrl+J换行后,第一行内容往上滚动被遮住 - 光标在新行,但下方显示一个空行 - 实际渲染了3行,但只显示2行 **尝试过的方案**: 1. 移除 lipgloss Width() 限制 - 无效 2. 设置 SetWidth() 后再 SetHeight() - 无效 3. 动态计算行数后调用 SetHeight() - 无效 4. 移除 updateInputHeight() 调用 - 无效 **根因分析**: - textarea 内部使用 viewport 组件管理滚动 - 每次按键后动态调用 `m.input.SetHeight(lines)` 调整高度 - 导致 textarea 内部 viewport 滚动位置与渲染不同步 **最终解决方案**: - 放弃动态调整高度的方案 - 固定 textarea 高度为5行 - 超过5行时,textarea 内部自动滚动,光标始终可见 **关键代码** (`internal/tui/model.go`): ```go ta.SetWidth(60) ta.SetHeight(5) // 固定高度,不动态调整 ``` ### 问题2:输入框背景颜色 **解决方案**: - 使用 `textarea.DefaultStyles()` 获取默认样式 - 修改 Style.Base 设置背景色 **关键代码**: ```go focusedStyle, blurredStyle := textarea.DefaultStyles() bgStyle := lipgloss.NewStyle(). Background(lipgloss.Color("#1F2937")). Foreground(lipgloss.Color("#FAFAFA")) focusedStyle.Base = bgStyle blurredStyle.Base = bgStyle ta.FocusedStyle = focusedStyle ta.BlurredStyle = blurredStyle ``` ### 经验总结 1. **Bubble Tea的textarea组件**:内部包含viewport,不适合频繁动态调整高度 2. **固定高度方案**:更稳定,让组件内部控制滚动 3. **样式设置**:使用 FocusedStyle/BlurredStyle + Style.Base 而非直接设置 Style --- ## Bubble Tea/Lipgloss v2 升级经验 (v0.8.1) ### 模块路径变更 v2 版本全部迁移到 `charm.land` 域名: | v1 | v2 | |----|-----| | `github.com/charmbracelet/lipgloss` | `charm.land/lipgloss/v2` | | `github.com/charmbracelet/bubbletea` | `charm.land/bubbletea/v2` | | `github.com/charmbracelet/bubbles` | `charm.land/bubbles/v2` | ### View() 方法变更 v1: ```go func (m model) View() string { return "内容" } ``` v2: ```go func (m model) View() tea.View { v := tea.NewView("内容") v.AltScreen = true // 进入备用屏幕 return v } ``` ### KeyMsg 变更 v1: ```go case tea.KeyMsg: switch msg.Type { case tea.KeyCtrlC: return m, tea.Quit } ``` v2: ```go case tea.KeyPressMsg: switch msg.String() { case "ctrl+c": return m, tea.Quit case "space": // 注意:空格键改为"space" } ``` ### 快捷键对比 | 功能 | v1 | v2 | |-----|-----|-----| | Ctrl+C | `tea.KeyCtrlC` | `"ctrl+c"` | | Alt修饰键 | `msg.Alt` | `msg.Mod.Contains(tea.ModAlt)` | | 空格键 | `" "` | `"space"` | ### viewport API变更 | 功能 | v1 | v2 | |-----|-----|-----| | 滚动上 | `m.viewport.LineUp(n)` | `m.viewport.ScrollUp(n)` | | 滚动下 | `m.viewport.LineDown(n)` | `m.viewport.ScrollDown(n)` | | 宽度 | `m.viewport.Width` | `m.viewport.Width()` | | 高度 | `m.viewport.Height` | `m.viewport.Height()` | | 创建 | `viewport.New(w, h)` | `viewport.New(viewport.WithWidth(w), viewport.WithHeight(h))` | ### textarea API变更 | 功能 | v1 | v2 | |-----|-----|-----| | 默认样式 | `textarea.DefaultStyles()` | `textarea.DefaultStyles(isDark bool)` | | 重置内容 | `m.input.SetValue("")` | `m.input.Reset()` | ### Program启动 v1: ```go p := tea.NewProgram(model{}) p.Start() ``` v2: ```go p := tea.NewProgram(model{}) p.Run() ``` --- ### Logo模块化设计 **决策**: 创建 `internal/logo/logo.go` 统一管理logo,TUI和CLI共享 **原因**: 1. 避免代码重复:TUI和CLI都需要显示logo 2. 统一渐变色方案:紫→青 (`#B413DC` → `#00C8C8`) 3. 统一版本号格式:` ( v1.x.x )` 或 ` ( )` **实现**: ```go // 导出函数供外部调用 func GradientText(text string, startColor, endColor string) string func GetLogoPattern() string // ASCII art图案 func GetVersionSuffix() string // " (v1.x.x )" 或 " ( )" func PrintLogoWithVersion() // 打印完整logo ``` **版本注入**: ```go // 编译时通过 ldflags 注入 // -X packagepath.variable=value go build -ldflags "-X github.com/titor/fanyi/internal/logo.version=${VERSION}" -o yoyo ./cmd/yoyo ``` --- ### CI构建环境问题 **问题1**: `./build.sh: not found **原因**: 1. 远程CI使用 `golang:1.26-alpine` 镜像,默认没有 bash 2. build.sh 脚本 shebang 是 `#!/bin/bash` **解决方案**: ```yaml # .gitea/workflows/release.yaml - name: Checkout run: | apk add git bash # 添加 bash git clone ... ``` --- **问题2**: `error obtaining VCS status: exit status 128` **原因**: CI 中 git 仓库信息不完整,导致 Go 获取 VCS 状态失败 **解决方案**: ```bash # build.sh 中添加 -buildvcs=false go build -buildvcs=false -ldflags "-s -w -X ..." -o yoyo ./cmd/yoyo ``` --- **问题3**: Release 说明只有 "Automated release" **原因**: 创建 release 时 body 写死了固定文本 **解决方案**: 使用 annotated tag 的注释内容作为 release 说明: ```bash git tag -a v1.2.0 -m "版本说明\n- 功能1\n- 功能2" ``` CI 中获取: ```bash RELEASE_BODY=$(git tag -l --format='%(contents)' "$TAG_NAME") ``` --- ### 终端颜色输出问题 **问题**: 在非TTY环境(如管道)下,ANSI转义序列可能显示为明文 **观察**: - 使用 `script` 命令可以正确显示颜色 - `od -c` 检查输出包含正确的 `\033` 转义字符 - zsh 可能对某些颜色转义处理不同 **解决方案**: - 使用 `GradientText` 函数逐字符应用渐变 - 每个字符后使用 `\033[0m` 重置 - 渐变使用 24-bit 颜色 `\033[38;2;R;G;Bm`