Files
mail/inbox/model.go
titor c9b77feabe feat: 收件箱功能新增按回车查看详情面板
- 添加邮件详情面板显示(主题、发件人、收件人、抄送、账户、时间、正文)
- 优化邮件列表卡片样式,增加选中高亮效果
- 窗口宽度 >= 80 时启用双面板布局,左侧列表右侧详情
- 简化依赖包,从 charm.land 使用统一导入路径
- 删除未使用的 golangci/goreleaser 配置文件
2026-04-10 04:41:22 +08:00

482 lines
12 KiB
Go

package inbox
import (
"fmt"
"io"
"strings"
"time"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/list"
"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
_ "charm.land/bubbles/v2/help"
)
type EmailDetail struct {
UID uint32
From string
FromName string
To string
Cc string
Subject string
Date time.Time
TextBody string
HTMLBody string
Account string
AccountID string
}
type DetailResultMsg struct {
Detail *EmailDetail
Err error
}
type EmailItem struct {
UID uint32
From string
FromName string
Subject string
Date time.Time
Preview string
Account string
AccountID string
}
func (e EmailItem) FilterValue() string {
return fmt.Sprintf("%s %s %s %s", e.Account, e.FromName, e.From, e.Subject)
}
func (e EmailItem) Title() string {
return fmt.Sprintf("%s · %s - %s", e.Account, e.FromName, e.Subject)
}
func (e EmailItem) Description() string {
return e.Preview
}
func formatTimeAgo(d time.Duration) string {
if d < time.Minute {
return "刚刚"
}
if d < time.Hour {
mins := int(d.Minutes())
return fmt.Sprintf("%d分钟前", mins)
}
if d < 24*time.Hour {
hours := int(d.Hours())
return fmt.Sprintf("%d小时前", hours)
}
if d < 7*24*time.Hour {
days := int(d.Hours() / 24)
return fmt.Sprintf("%d天前", days)
}
return d.Truncate(24 * time.Hour).String()
}
func truncateString(s string, maxWidth int) string {
if maxWidth <= 0 {
return ""
}
runes := []rune(s)
if len(runes) <= maxWidth {
return s
}
if maxWidth <= 3 {
return strings.Repeat(".", maxWidth)
}
return string(runes[:maxWidth-3]) + "..."
}
type InboxModel struct {
list list.Model
emails []EmailItem
loading bool
err error
selectedEmail *EmailItem
selectedDetail *EmailDetail
loadingDetail bool
windowWidth int
windowHeight int
helpKeyMap inboxHelpKeyMap
spinner spinner.Model
detailFetcher DetailFetcher
}
type inboxHelpKeyMap struct{}
func (i inboxHelpKeyMap) ShortHelp() []key.Binding {
return []key.Binding{
key.NewBinding(key.WithHelp("↑", "上移")),
key.NewBinding(key.WithHelp("↓", "下移")),
key.NewBinding(key.WithHelp("enter", "查看详情")),
key.NewBinding(key.WithHelp("/", "搜索")),
key.NewBinding(key.WithHelp("q", "退出")),
}
}
func (i inboxHelpKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{
key.NewBinding(key.WithHelp("↑↓", "移动")),
key.NewBinding(key.WithHelp("enter", "查看详情")),
key.NewBinding(key.WithHelp("/", "搜索")),
key.NewBinding(key.WithHelp("q", "退出")),
},
}
}
func NewInboxModel() *InboxModel {
l := list.New(nil, emailDelegate{}, 40, 14)
l.Title = "收件箱"
l.Styles = list.DefaultStyles(true)
l.Styles.Title = inboxTitleStyle
l.Styles.NoItems = inboxNoItemsStyle
l.SetShowHelp(true)
l.SetShowPagination(false)
l.SetFilteringEnabled(true)
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = spinnerStyle
return &InboxModel{
list: l,
emails: []EmailItem{},
windowWidth: 0,
spinner: s,
}
}
func (m *InboxModel) SetEmails(emails []EmailItem) {
m.emails = emails
items := make([]list.Item, len(emails))
for i, e := range emails {
items[i] = e
}
m.list.SetItems(items)
m.list.Title = fmt.Sprintf("收件箱 (%d 封新邮件)", len(items))
}
func (m InboxModel) Init() tea.Cmd {
return tea.Batch(
func() tea.Msg {
return tea.WindowSizeMsg{Width: 120, Height: 30}
},
m.spinner.Tick,
)
}
func fetchEmailDetail(accountID string, uid uint32, fetcher DetailFetcher) tea.Cmd {
return func() tea.Msg {
detail, err := fetcher(accountID, uid)
return DetailResultMsg{Detail: detail, Err: err}
}
}
func (m *InboxModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
if msg.String() == "enter" {
email := m.SelectedEmail()
if email != nil && m.detailFetcher != nil {
m.loadingDetail = true
m.selectedDetail = nil
return m, fetchEmailDetail(email.AccountID, email.UID, m.detailFetcher)
}
}
case tea.WindowSizeMsg:
m.windowWidth = msg.Width
m.windowHeight = msg.Height
m.list.SetWidth(calculateListWidth(msg.Width))
m.list.SetHeight(int(float64(msg.Height) * 0.8))
case DetailResultMsg:
m.loadingDetail = false
if msg.Err != nil {
m.err = msg.Err
} else {
m.selectedDetail = msg.Detail
}
case spinner.TickMsg:
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func calculateListWidth(totalWidth int) int {
if totalWidth <= 0 {
return 0
}
return int(float64(totalWidth) * 0.45)
}
func (m InboxModel) View() tea.View {
if m.loading {
return tea.NewView(loadingStyle.Render("正在加载邮件..."))
}
if m.err != nil {
return tea.NewView(errorStyle.Render(fmt.Sprintf("错误: %v", m.err)))
}
listWidth := calculateListWidth(m.windowWidth)
detailWidth := m.windowWidth - listWidth - 1
m.list.SetWidth(listWidth)
mainContentStyle := lipgloss.NewStyle().
Width(m.windowWidth - 1).
Height(m.windowHeight - 2)
helpBarStyle := lipgloss.NewStyle().
Width(m.windowWidth - 1).
Foreground(lipgloss.Color("241"))
helpView := helpBarStyle.Render(m.list.Help.View(m.helpKeyMap))
if m.windowWidth >= 80 {
detailView := m.renderDetailPanel(detailWidth)
listView := m.list.View()
mainContent := lipgloss.JoinHorizontal(lipgloss.Top, listView, detailView)
box := mainContentStyle.Render(mainContent)
content := lipgloss.JoinVertical(lipgloss.Bottom, box, helpView)
v := tea.NewView(content)
v.AltScreen = true
return v
}
listView := m.list.View()
box := mainContentStyle.Render(listView)
content := lipgloss.JoinVertical(lipgloss.Bottom, box, helpView)
v := tea.NewView(content)
v.AltScreen = true
return v
}
func (m InboxModel) renderHelpBar() string {
helpItems := "↑↓ 移动 │ enter 查看详情 │ / 搜索 │ q 退出"
return helpBarStyle.Render(helpItems)
}
func (m InboxModel) renderDetailPanel(listWidth int) string {
email := m.SelectedEmail()
if email == nil {
return detailPanelStyle.Render("选择一封邮件查看详情")
}
if m.loadingDetail {
spinnerView := m.spinner.View()
loadingText := detailMetaStyle.Render(spinnerView + " 正在加载邮件内容...")
return detailPanelStyle.Width(40).Render(loadingText)
}
if m.selectedDetail != nil {
detail := m.selectedDetail
subject := detailTitleStyle.Render(detail.Subject)
from := detailMetaStyle.Render(fmt.Sprintf("发件人: %s", detail.From))
to := detailMetaStyle.Render(fmt.Sprintf("收件人: %s", detail.To))
if detail.Cc != "" {
to += "\n" + detailMetaStyle.Render(fmt.Sprintf("抄送: %s", detail.Cc))
}
account := detailMetaStyle.Render(fmt.Sprintf("账户: %s", detail.Account))
date := detailMetaStyle.Render(fmt.Sprintf("时间: %s", detail.Date.Format("2006-01-02 15:04")))
var body string
if detail.TextBody != "" {
body = detailBodyStyle.Render(detail.TextBody)
} else if detail.HTMLBody != "" {
body = detailMetaStyle.Render("[HTML邮件内容]")
} else {
body = detailMetaStyle.Render("[无正文]")
}
content := fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n\n%s", subject, from, to, account, date, body)
detailWidth := 40
if m.windowWidth > 0 {
detailWidth = m.windowWidth - listWidth - 1
}
if detailWidth < 10 {
detailWidth = 40
}
return detailPanelStyle.Width(detailWidth).Render(content)
}
subject := detailTitleStyle.Render(email.Subject)
from := detailMetaStyle.Render(fmt.Sprintf("发件人: %s <%s>", email.FromName, email.From))
account := detailMetaStyle.Render(fmt.Sprintf("账户: %s", email.Account))
date := detailMetaStyle.Render(fmt.Sprintf("时间: %s", email.Date.Format("2006-01-02 15:04")))
preview := detailPanelStyle.Render(email.Preview)
content := fmt.Sprintf("%s\n%s\n%s\n%s\n\n%s", subject, from, account, date, preview)
detailWidth := 40
if m.windowWidth > 0 {
detailWidth = m.windowWidth - listWidth - 1
}
if detailWidth < 10 {
detailWidth = 40
}
return detailPanelStyle.Width(detailWidth).Render(content)
}
func (m *InboxModel) SetLoading(loading bool) {
m.loading = loading
}
func (m *InboxModel) SetError(err error) {
m.err = err
}
func (m InboxModel) SelectedEmail() *EmailItem {
idx := m.list.Index()
if idx >= 0 && idx < len(m.emails) {
return &m.emails[idx]
}
return nil
}
var (
inboxTitleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("219")).
Bold(true)
inboxNoItemsStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
Padding(1, 2)
loadingStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("219")).
Padding(1, 2)
errorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("196")).
Padding(1, 2)
emailCardSubjectStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("255")).
Bold(true).
Padding(0, 2)
emailCardMetaStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
Padding(0, 2)
emailCardSubjectSelectedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("255")).
Bold(true).
Background(lipgloss.Color("99")).
Border(lipgloss.NormalBorder()).
BorderLeft(true).
BorderForeground(lipgloss.Color("219")).
Padding(0, 2)
emailCardMetaSelectedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("186")).
Background(lipgloss.Color("99")).
Border(lipgloss.NormalBorder()).
BorderLeft(true).
BorderForeground(lipgloss.Color("219")).
Padding(0, 2)
detailPanelStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("255")).
Padding(1, 2)
detailTitleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("219")).
Bold(true).
Padding(0, 1)
detailMetaStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
Padding(0, 1)
detailBodyStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("252")).
Padding(0, 1)
helpBarStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
Padding(0, 1)
spinnerStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("219"))
cardSpacing = 1
)
type DetailFetcher func(accountID string, uid uint32) (*EmailDetail, error)
func (m *InboxModel) SetDetailFetcher(fetcher DetailFetcher) {
m.detailFetcher = fetcher
}
type emailDelegate struct{}
func (d emailDelegate) Height() int { return 2 }
func (d emailDelegate) Spacing() int { return 0 }
func (d emailDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
return nil
}
func (d emailDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) {
email, ok := item.(EmailItem)
if !ok {
return
}
isSelected := index == m.Index()
width := m.Width() - 1
subjectWidth := width - 2
metaWidth := width - 2
subject := truncateString(email.Subject, subjectWidth)
meta := truncateString("▣ "+email.Account+" · "+formatTimeAgo(time.Since(email.Date)), metaWidth)
if isSelected {
subjectStyle := lipgloss.NewStyle().
Width(width).
Foreground(lipgloss.Color("255")).
Background(lipgloss.Color("99")).
Padding(1, 1, 0, 1)
metaStyle := lipgloss.NewStyle().
Width(width).
Foreground(lipgloss.Color("186")).
Background(lipgloss.Color("99")).
Padding(0, 1, 1, 1)
fmt.Fprintf(w, "%s\n%s",
subjectStyle.Render(subject),
metaStyle.Render(meta))
} else {
subjectStyle := lipgloss.NewStyle().
Width(width).
Foreground(lipgloss.Color("255")).
Padding(1, 1, 0, 1)
metaStyle := lipgloss.NewStyle().
Width(width).
Foreground(lipgloss.Color("241")).
Padding(0, 1, 1, 1)
fmt.Fprintf(w, "%s\n%s",
subjectStyle.Render(subject),
metaStyle.Render(meta))
}
}