feat: 添加图表统计功能
- TG: /chart 本月分类饼图, /week 近7天消费柱状图 - QQ: 统计/报表 本月文本统计 - 新增 go-chart 依赖生成 PNG 图表 - 新增 GetCategoryStats/GetDailyStats 查询方法
This commit is contained in:
@@ -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
126
internal/chart/chart.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user