feat: 添加图表统计功能

- TG: /chart 本月分类饼图, /week 近7天消费柱状图
- QQ: 统计/报表 本月文本统计
- 新增 go-chart 依赖生成 PNG 图表
- 新增 GetCategoryStats/GetDailyStats 查询方法
This commit is contained in:
2026-02-15 21:52:03 +08:00
parent ebe8d92c75
commit bac7a7b708
6 changed files with 317 additions and 7 deletions

View File

@@ -7,6 +7,7 @@ import (
"strings"
"time"
xchart "xiaji-go/internal/chart"
"xiaji-go/internal/service"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
@@ -61,10 +62,10 @@ func (b *TGBot) handleMessage(msg *tgbotapi.Message) {
switch {
case text == "/start":
reply = "🦞 欢迎使用虾记记账!\n\n直接发送消费描述即可记账例如\n• 午饭 25元\n• 打车 ¥30\n• 买咖啡15块\n\n命令\n/list - 查看最近记录\n/help - 帮助"
reply = "🦞 欢迎使用虾记记账!\n\n直接发送消费描述即可记账例如\n• 午饭 25元\n• 打车 ¥30\n• 买咖啡15块\n\n命令\n/list - 查看最近记录\n/today - 今日汇总\n/chart - 本月图表\n/help - 帮助"
case text == "/help":
reply = "📖 使用说明:\n\n直接发送带金额的文本即可自动记账。\n系统会自动识别金额和消费分类。\n\n支持格式\n• 午饭 25元\n• ¥30 打车\n• 买水果15块\n\n命令\n/list - 最近10条记录\n/today - 今日汇总\n/start - 欢迎信息"
reply = "📖 使用说明:\n\n直接发送带金额的文本即可自动记账。\n系统会自动识别金额和消费分类。\n\n支持格式\n• 午饭 25元\n• ¥30 打车\n• 买水果15块\n\n命令\n/list - 最近10条记录\n/today - 今日汇总\n/chart - 本月消费图表\n/week - 近7天每日趋势\n/start - 欢迎信息"
case text == "/today":
today := time.Now().Format("2006-01-02")
@@ -85,6 +86,14 @@ func (b *TGBot) handleMessage(msg *tgbotapi.Message) {
reply = sb.String()
}
case text == "/chart":
b.sendMonthlyChart(chatID)
return
case text == "/week":
b.sendWeeklyChart(chatID)
return
case text == "/list":
items, err := b.finance.GetTransactions(DefaultUserID, 10)
if err != nil {
@@ -122,3 +131,79 @@ func (b *TGBot) handleMessage(msg *tgbotapi.Message) {
log.Printf("发送消息失败 chat=%d: %v", chatID, err)
}
}
// sendMonthlyChart 发送本月分类饼图
func (b *TGBot) sendMonthlyChart(chatID int64) {
now := time.Now()
dateFrom := now.Format("2006-01") + "-01"
dateTo := now.Format("2006-01-02")
title := fmt.Sprintf("%d年%d月消费分类", now.Year(), now.Month())
stats, err := b.finance.GetCategoryStats(DefaultUserID, dateFrom, dateTo)
if err != nil || len(stats) == 0 {
m := tgbotapi.NewMessage(chatID, "📭 本月暂无消费数据")
b.api.Send(m)
return
}
imgData, err := xchart.GeneratePieChart(stats, title)
if err != nil {
log.Printf("生成饼图失败: %v", err)
m := tgbotapi.NewMessage(chatID, "❌ 图表生成失败")
b.api.Send(m)
return
}
// 计算总计文字
var total int64
var totalCount int
for _, s := range stats {
total += s.Total
totalCount += s.Count
}
caption := fmt.Sprintf("📊 %s\n💰 共 %d 笔,合计 %.2f 元", title, totalCount, float64(total)/100.0)
photo := tgbotapi.NewPhoto(chatID, tgbotapi.FileBytes{Name: "chart.png", Bytes: imgData})
photo.Caption = caption
if _, err := b.api.Send(photo); err != nil {
log.Printf("发送图表失败 chat=%d: %v", chatID, err)
}
}
// sendWeeklyChart 发送近7天每日消费柱状图
func (b *TGBot) sendWeeklyChart(chatID int64) {
now := time.Now()
dateFrom := now.AddDate(0, 0, -6).Format("2006-01-02")
dateTo := now.Format("2006-01-02")
title := fmt.Sprintf("近7天消费趋势 (%s ~ %s)", dateFrom[5:], dateTo[5:])
stats, err := b.finance.GetDailyStats(DefaultUserID, dateFrom, dateTo)
if err != nil || len(stats) == 0 {
m := tgbotapi.NewMessage(chatID, "📭 近7天暂无消费数据")
b.api.Send(m)
return
}
imgData, err := xchart.GenerateBarChart(stats, title)
if err != nil {
log.Printf("生成柱状图失败: %v", err)
m := tgbotapi.NewMessage(chatID, "❌ 图表生成失败")
b.api.Send(m)
return
}
// 总计
var total int64
var totalCount int
for _, s := range stats {
total += s.Total
totalCount += s.Count
}
caption := fmt.Sprintf("📈 %s\n💰 共 %d 笔,合计 %.2f 元", title, totalCount, float64(total)/100.0)
photo := tgbotapi.NewPhoto(chatID, tgbotapi.FileBytes{Name: "chart.png", Bytes: imgData})
photo.Caption = caption
if _, err := b.api.Send(photo); err != nil {
log.Printf("发送图表失败 chat=%d: %v", chatID, err)
}
}

126
internal/chart/chart.go Normal file
View File

@@ -0,0 +1,126 @@
package chart
import (
"bytes"
"fmt"
"math"
"xiaji-go/internal/service"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
)
// 分类对应的颜色
var categoryColors = []drawing.Color{
{R: 255, G: 99, B: 132, A: 255}, // 红
{R: 54, G: 162, B: 235, A: 255}, // 蓝
{R: 255, G: 206, B: 86, A: 255}, // 黄
{R: 75, G: 192, B: 192, A: 255}, // 青
{R: 153, G: 102, B: 255, A: 255}, // 紫
{R: 255, G: 159, B: 64, A: 255}, // 橙
{R: 46, G: 204, B: 113, A: 255}, // 绿
{R: 231, G: 76, B: 60, A: 255}, // 深红
{R: 52, G: 73, B: 94, A: 255}, // 深蓝灰
{R: 241, G: 196, B: 15, A: 255}, // 金
}
// GeneratePieChart 生成分类占比饼图
func GeneratePieChart(stats []service.CategoryStat, title string) ([]byte, error) {
if len(stats) == 0 {
return nil, fmt.Errorf("no data")
}
var total float64
for _, s := range stats {
total += float64(s.Total)
}
var values []chart.Value
for i, s := range stats {
yuan := float64(s.Total) / 100.0
pct := float64(s.Total) / total * 100
label := fmt.Sprintf("%s %.0f元(%.0f%%)", s.Category, yuan, pct)
values = append(values, chart.Value{
Value: yuan,
Label: label,
Style: chart.Style{
FillColor: categoryColors[i%len(categoryColors)],
StrokeColor: drawing.ColorWhite,
StrokeWidth: 2,
},
})
}
pie := chart.PieChart{
Title: title,
Width: 600,
Height: 500,
TitleStyle: chart.Style{
FontSize: 16,
},
Values: values,
}
buf := &bytes.Buffer{}
if err := pie.Render(chart.PNG, buf); err != nil {
return nil, fmt.Errorf("render pie chart: %w", err)
}
return buf.Bytes(), nil
}
// GenerateBarChart 生成每日消费柱状图
func GenerateBarChart(stats []service.DailyStat, title string) ([]byte, error) {
if len(stats) == 0 {
return nil, fmt.Errorf("no data")
}
var values []chart.Value
var maxVal float64
for _, s := range stats {
yuan := float64(s.Total) / 100.0
if yuan > maxVal {
maxVal = yuan
}
// 日期只取 MM-DD
dateLabel := s.Date
if len(s.Date) > 5 {
dateLabel = s.Date[5:]
}
values = append(values, chart.Value{
Value: yuan,
Label: dateLabel,
Style: chart.Style{
FillColor: drawing.Color{R: 54, G: 162, B: 235, A: 255},
StrokeColor: drawing.Color{R: 54, G: 162, B: 235, A: 255},
StrokeWidth: 1,
},
})
}
bar := chart.BarChart{
Title: title,
Width: 600,
Height: 400,
TitleStyle: chart.Style{
FontSize: 16,
},
YAxis: chart.YAxis{
Range: &chart.ContinuousRange{
Min: 0,
Max: math.Ceil(maxVal*1.2/10) * 10,
},
ValueFormatter: func(v interface{}) string {
return fmt.Sprintf("%.0f", v)
},
},
BarWidth: 40,
Bars: values,
}
buf := &bytes.Buffer{}
if err := bar.Render(chart.PNG, buf); err != nil {
return nil, fmt.Errorf("render bar chart: %w", err)
}
return buf.Bytes(), nil
}

View File

@@ -102,6 +102,7 @@ func (b *QQBot) processAndReply(userID string, content string) string {
"📋 命令列表:\n" +
"• 记录/查看 — 最近10条\n" +
"• 今日/今天 — 今日汇总\n" +
"• 统计/报表 — 本月分类统计\n" +
"• 帮助 — 本帮助信息"
case isCommand(text, "查看", "记录", "列表", "list", "/list", "最近"):
@@ -136,6 +137,27 @@ func (b *QQBot) processAndReply(userID string, content string) string {
}
sb.WriteString(fmt.Sprintf("\n💰 共 %d 笔,合计 %.2f 元", len(items), float64(total)/100.0))
return sb.String()
case isCommand(text, "统计", "报表", "图表", "chart", "/chart"):
now := time.Now()
dateFrom := now.Format("2006-01") + "-01"
dateTo := now.Format("2006-01-02")
stats, err := b.finance.GetCategoryStats(DefaultUserID, dateFrom, dateTo)
if err != nil || len(stats) == 0 {
return fmt.Sprintf("📭 %d年%d月暂无消费数据", now.Year(), now.Month())
}
var sb strings.Builder
var grandTotal int64
var grandCount int
sb.WriteString(fmt.Sprintf("📊 %d年%d月消费统计\n\n", now.Year(), now.Month()))
for _, s := range stats {
yuan := float64(s.Total) / 100.0
sb.WriteString(fmt.Sprintf("• %s%.2f元(%d笔\n", s.Category, yuan, s.Count))
grandTotal += s.Total
grandCount += s.Count
}
sb.WriteString(fmt.Sprintf("\n💰 共 %d 笔,合计 %.2f 元", grandCount, float64(grandTotal)/100.0))
return sb.String()
}
amount, category, err := b.finance.AddTransaction(DefaultUserID, text)

View File

@@ -114,3 +114,41 @@ func (s *FinanceService) GetTransactionsByDate(userID int64, date string) ([]mod
Order("id desc").Find(&items).Error
return items, err
}
// CategoryStat 分类统计结果
type CategoryStat struct {
Category string
Total int64
Count int
}
// GetCategoryStats 获取用户指定日期范围的分类统计
func (s *FinanceService) GetCategoryStats(userID int64, dateFrom, dateTo string) ([]CategoryStat, error) {
var stats []CategoryStat
err := s.db.Model(&models.Transaction{}).
Select("category, SUM(amount) as total, COUNT(*) as count").
Where("user_id = ? AND date >= ? AND date <= ? AND is_deleted = ?", userID, dateFrom, dateTo, false).
Group("category").
Order("total desc").
Find(&stats).Error
return stats, err
}
// DailyStat 每日统计结果
type DailyStat struct {
Date string
Total int64
Count int
}
// GetDailyStats 获取用户指定日期范围的每日统计
func (s *FinanceService) GetDailyStats(userID int64, dateFrom, dateTo string) ([]DailyStat, error) {
var stats []DailyStat
err := s.db.Model(&models.Transaction{}).
Select("date, SUM(amount) as total, COUNT(*) as count").
Where("user_id = ? AND date >= ? AND date <= ? AND is_deleted = ?", userID, dateFrom, dateTo, false).
Group("date").
Order("date asc").
Find(&stats).Error
return stats, err
}