AI书签管理工具开发全记录(十五):TUI基本逻辑实现与数据展示

发布于:2025-06-08 ⋅ 阅读:(21) ⋅ 点赞:(0)

AI书签管理工具开发全记录(十五):TUI基本逻辑实现与数据展示

1.前言 📝

在上一篇文章中,我们完善了TUI的基本功能框架,增加了焦点切换功能。并且增加了日志显示功能支持。本文将聚焦于书签数据的页面展示。

2.核心要点分析

2.1 核心分析

categoryList    *tview.List
bookmarkList    *tview.List

categoryList和bookmarkList都是tview.List。
AddItem定义如下:

func (l *List) AddItem(mainText, secondaryText string, shortcut rune, selected func()) *List {
	l.InsertItem(-1, mainText, secondaryText, shortcut, selected)
	return l
}

这意味着我们只能存储一级分类标题,和二级分类标题。无法存放id之类的数据,更别说更多的自定义数据。

2.2 解决方案

将当前分类数据完整信息进行存储,后续通过选中的index判断当前选中的数据项。

// 存储分类信息 按index存储
categoryCache map[int]models.Category
// 存储书签信息 按index存储
bookmarkCache map[int]models.Bookmark

每次进行渲染分类时,除了需要清空列表数据,还需要重建分类信息缓存。

// 清空分类列表
t.categoryList.Clear()
t.categoryCache = make(map[int]models.Category)

3. 程序流程

3.1 用户交互流程

焦点变化
左箭头
右箭头
Ctrl+F
Ctrl+R
ESC或q
Enter
更新焦点索引
向左切换焦点
向右切换焦点
设置新焦点组件样式
用户按键
按键类型
激活搜索框
重置搜索
退出程序
用户输入搜索词
执行搜索
清空当前数据
查询数据库
分组匹配结果
显示分类列表
显示匹配的第一个分类的书签
显示书签详情
重置搜索状态
加载完整分类列表
显示第一个分类的书签
显示书签详情

3.功能实现

3.1 书签分类渲染

func (t *TuiView) RenderCategoryList() {
	// 清空分类列表
	t.categoryList.Clear()

	// 从数据库查询分类列表
	var categories []models.Category
	if err := t.db.Find(&categories).Error; err != nil {
		// 处理错误,例如记录日志或显示错误信息
		return
	}

	// 清空分类列表
	t.categoryList.Clear()
	t.categoryCache = make(map[int]models.Category)

	// 渲染分类列表到tui界面
	for _, category := range categories {
		t.categoryList.AddItem(category.Name, category.Description, 0, nil)
		// 存储分类信息
		t.categoryCache[t.categoryList.GetItemCount()-1] = category
	}
	// 如果没有分类,显示提示信息
	if t.categoryList.GetItemCount() == 0 {
		t.categoryList.AddItem("无分类", "先去添加分类吧", 0, nil)
	} else {
		t.categoryList.SetCurrentItem(0)
		t.RenderBookmarksView(categories[0].ID)
	}
}

3.2 书签分类渲染

// 添加RenderBookmarksView函数
func (t *TuiView) RenderBookmarksView(categoryID uint) {
	t.LogInfo("RenderBookmarksView: " + strconv.FormatUint(uint64(categoryID), 10))
	// 清空书签列表
	t.bookmarkList.Clear()
	t.bookmarkCache = make(map[int]models.Bookmark)

	// 从数据库查询指定分类的书签列表
	var bookmarks []models.Bookmark
	if err := t.db.Where("category_id = ?", categoryID).Find(&bookmarks).Error; err != nil {
		// 处理错误,例如记录日志或显示错误信息
		return
	}

	// 渲染书签列表到tui界面
	for _, bookmark := range bookmarks {
		t.bookmarkList.AddItem(bookmark.Title, bookmark.URL, 0, nil)
		// 存储书签信息
		t.bookmarkCache[t.bookmarkList.GetItemCount()-1] = bookmark
	}
	if t.bookmarkList.GetItemCount() > 0 {
		t.bookmarkList.SetCurrentItem(0)
		t.RenderBookmarkDetail(bookmarks[0].ID)
	}
}

3.3 书签描述渲染

书签描述支持markdown,所以我们需要对markdown语法进行支持,更好的在终端中进行渲染。

我们需要使用到glamour
安装

go get -u "github.com/charmbracelet/glamour"

编写工具函数

package utils

import (
	"fmt"

	"github.com/charmbracelet/glamour"
)

var MarkdownInstance = Markdown{}

type Markdown struct {
	renderer *glamour.TermRenderer
}

func init() {
	renderer, err := glamour.NewTermRenderer(
		glamour.WithStandardStyle("dark"),
		glamour.WithWordWrap(120),
		glamour.WithEmoji(),
	)
	if err != nil {
		fmt.Println(err)
		panic(err)
	}
	MarkdownInstance.renderer = renderer

}

func (c *Markdown) MarkdownRender(content string) (string, error) {
	out, err := c.renderer.Render(content)
	if err != nil {
		return "", err
	}
	return out, nil
}

书签描述渲染

// 完善RenderBookmarkDetail函数,调用markdown渲染
func (t *TuiView) RenderBookmarkDetail(bookmarkID uint) {
	// 从数据库查询书签详情
	var bookmark models.Bookmark
	if err := t.db.First(&bookmark, bookmarkID).Error; err != nil {
		// 处理错误,例如记录日志或显示错误信息
		return
	}

	// 调用markdown渲染
	renderedContent, err := utils.MarkdownInstance.MarkdownRender(bookmark.Description)
	if err != nil {
		// 处理错误,例如记录日志或显示错误信息
		return
	}

	// 渲染书签详情到tui界面
	t.descriptionView.SetText(tview.TranslateANSI(renderedContent))
}

注意必须调用tview.TranslateANSI方法,否则终端会展示一堆乱码。

3.3 搜索功能实现

func (t *TuiView) SearchBookmarks(keyword string) {
	t.isSearching = true
	t.searchBox.SetText(keyword)
	t.bookmarkList.Clear()
	t.categoryList.Clear()
	t.categoryCache = make(map[int]models.Category)
	t.bookmarkCache = make(map[int]models.Bookmark)

	// 从数据库查询匹配的书签
	var bookmarks []models.Bookmark
	if err := t.db.Where("title LIKE ? OR category_id IN (SELECT id FROM categories WHERE name LIKE ?)", "%"+keyword+"%", "%"+keyword+"%").Find(&bookmarks).Error; err != nil {
		// 处理错误,例如记录日志或显示错误信息
		return
	}

	// 将书签按分类分组
	categoryBookmarks := make(map[uint][]models.Bookmark)
	for _, bookmark := range bookmarks {
		categoryBookmarks[bookmark.CategoryID] = append(categoryBookmarks[bookmark.CategoryID], bookmark)
	}

	// 查询所有包含匹配书签的分类
	var categories []models.Category
	if err := t.db.Where("id IN (?)", t.db.Table("bookmarks").Select("category_id").Where("title LIKE ? OR category_id IN (SELECT id FROM categories WHERE name LIKE ?)", "%"+keyword+"%", "%"+keyword+"%")).Find(&categories).Error; err != nil {
		// 处理错误,例如记录日志或显示错误信息
		return
	}

	// 渲染分类列表到tui界面
	for _, category := range categories {
		t.categoryList.AddItem(category.Name, category.Description, 0, nil)
		// 存储分类信息
		t.categoryCache[t.categoryList.GetItemCount()-1] = category
	}

	// 如果没有匹配的分类,显示提示信息
	if t.categoryList.GetItemCount() == 0 {
		t.categoryList.AddItem("无匹配分类", "请尝试其他关键词", 0, nil)
	} else {
		// 渲染第一个分类下的书签
		if bookmarks, ok := categoryBookmarks[categories[0].ID]; ok {
			for _, bookmark := range bookmarks {
				t.bookmarkList.AddItem(bookmark.Title, bookmark.URL, 0, nil)
				// 存储书签信息
				t.bookmarkCache[t.bookmarkList.GetItemCount()-1] = bookmark
			}
			if t.bookmarkList.GetItemCount() > 0 {
				t.bookmarkList.SetCurrentItem(0)
				t.RenderBookmarkDetail(bookmarks[0].ID)
			}
		}
		t.categoryList.SetCurrentItem(0)
	}
}

需要注意,一定要屏蔽掉原有的书签查询逻辑,否则会造成数据异常。

3.4 事件绑定

绑定事件后,切换分类时,数据才会实时查询渲染。当搜索时,我们交由搜索函数自生处理搜索逻辑。

func (t *TuiView) BindChangeFunc() {
	t.categoryList.SetChangedFunc(func(index int, mainText, secondaryText string, shortcut rune) {
		if t.isSearching {
			return
		}
		// 获取当前分类列表
		category := t.categoryCache[index]
		t.LogInfo(fmt.Sprintf("category: %v", category))
		t.RenderBookmarksView(category.ID)
	})
	t.bookmarkList.SetChangedFunc(func(index int, mainText, secondaryText string, shortcut rune) {
		bookmark := t.bookmarkCache[index]
		t.LogInfo(fmt.Sprintf("bookmark: %v", bookmark))
		t.RenderBookmarkDetail(bookmark.ID)
	})
	t.searchBox.SetDoneFunc(func(key tcell.Key) {
		if key == tcell.KeyEnter {
			t.SearchBookmarks(t.searchBox.GetText())
			// 设置焦点到书签列表
			t.Focus(t.bookmarkList)
		} else if key == tcell.KeyF2 {
			t.ResetSearch()
			t.searchBox.SetText("")
		}
	})
}

3.5 启动时渲染分类

根据是否传入了搜索关键词来决定渲染逻辑。

// 如果搜索词不为空,直接加载搜索结果
if searchKeyword != "" {
	c.searchBox.SetText(searchKeyword)
	c.Search(searchKeyword)
	c.focusIndex = 1
} else {
	c.RenderCategoryView()
}

4.演示

aibookmark04.gif

可以快速进行书签的浏览和查询


往期系列