feat: 用量统计页改版,月度汇总支持多指标图表
- 新增 GET /api/usage/monthly?year= 年度按自然月聚合,每月含按模型分解(token 降序) - 月度汇总改为堆叠柱状图:Token/消费金额/调用次数三指标切换,按模型分色(图例取前 8,其余归入「其他」) - 选中月份概览卡片(金额/次数/token 分解),点击柱体或图例联动切换 - 年份切换、悬停明细 tooltip、请求明细保留
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -77,6 +79,130 @@ func (h *Handler) MyUsageStats(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// MyUsageMonthly GET /api/usage/monthly?year=2026 — 当前用户年度按自然月聚合,
|
||||
// 每月含按模型分解(供月度堆叠柱状图使用)。
|
||||
func (h *Handler) MyUsageMonthly(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
uid, _ := userID.(uint64)
|
||||
|
||||
year := time.Now().Year()
|
||||
if y := c.Query("year"); y != "" {
|
||||
if n, err := strconv.Atoi(y); err == nil && n >= 2000 && n <= 2100 {
|
||||
year = n
|
||||
}
|
||||
}
|
||||
start := time.Date(year, 1, 1, 0, 0, 0, 0, time.Local)
|
||||
end := start.AddDate(1, 0, -1)
|
||||
|
||||
dailies, err := h.dailyDAO.ListByDateRange(c.Request.Context(), uid, start, end)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load usage"})
|
||||
return
|
||||
}
|
||||
|
||||
// 补齐模型名(模型可能已被删除,回退为 模型#id)
|
||||
modelIDs := make([]uint64, 0, len(dailies))
|
||||
seen := map[uint64]bool{}
|
||||
for _, d := range dailies {
|
||||
if !seen[d.ModelID] {
|
||||
seen[d.ModelID] = true
|
||||
modelIDs = append(modelIDs, d.ModelID)
|
||||
}
|
||||
}
|
||||
modelNames := map[uint64]string{}
|
||||
if len(modelIDs) > 0 {
|
||||
var models []store.Model
|
||||
if err := h.db.Where("id IN ?", modelIDs).Find(&models).Error; err == nil {
|
||||
for _, m := range models {
|
||||
modelNames[m.ID] = m.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type modelAgg struct {
|
||||
ModelID uint64 `json:"model_id"`
|
||||
ModelName string `json:"model_name"`
|
||||
Requests int64 `json:"requests"`
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
CacheReadTokens int64 `json:"cache_read_tokens"`
|
||||
Cost float64 `json:"cost"`
|
||||
}
|
||||
type monthAgg struct {
|
||||
Month string `json:"month"`
|
||||
Requests int64 `json:"requests"`
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
CacheReadTokens int64 `json:"cache_read_tokens"`
|
||||
Cost float64 `json:"cost"`
|
||||
Models map[uint64]*modelAgg `json:"-"`
|
||||
}
|
||||
|
||||
months := make([]*monthAgg, 12)
|
||||
for i := range months {
|
||||
months[i] = &monthAgg{
|
||||
Month: fmt.Sprintf("%d-%02d", year, i+1),
|
||||
Models: map[uint64]*modelAgg{},
|
||||
}
|
||||
}
|
||||
for _, d := range dailies {
|
||||
mm, err := strconv.Atoi(d.Date[5:7])
|
||||
if err != nil || mm < 1 || mm > 12 {
|
||||
continue
|
||||
}
|
||||
m := months[mm-1]
|
||||
m.Requests += d.Requests
|
||||
m.InputTokens += d.InputTokens
|
||||
m.OutputTokens += d.OutputTokens
|
||||
m.CacheReadTokens += d.CacheReadTokens
|
||||
m.Cost += d.Cost
|
||||
ma, ok := m.Models[d.ModelID]
|
||||
if !ok {
|
||||
name := modelNames[d.ModelID]
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("模型#%d", d.ModelID)
|
||||
}
|
||||
ma = &modelAgg{ModelID: d.ModelID, ModelName: name}
|
||||
m.Models[d.ModelID] = ma
|
||||
}
|
||||
ma.Requests += d.Requests
|
||||
ma.InputTokens += d.InputTokens
|
||||
ma.OutputTokens += d.OutputTokens
|
||||
ma.CacheReadTokens += d.CacheReadTokens
|
||||
ma.Cost += d.Cost
|
||||
}
|
||||
|
||||
out := make([]gin.H, 12)
|
||||
for i, m := range months {
|
||||
modelList := make([]*modelAgg, 0, len(m.Models))
|
||||
for _, ma := range m.Models {
|
||||
modelList = append(modelList, ma)
|
||||
}
|
||||
// 模型按 token 总量降序,柱状图图例顺序与之一致
|
||||
sort.Slice(modelList, func(a, b int) bool {
|
||||
ta := modelList[a].InputTokens + modelList[a].OutputTokens + modelList[a].CacheReadTokens
|
||||
tb := modelList[b].InputTokens + modelList[b].OutputTokens + modelList[b].CacheReadTokens
|
||||
return ta > tb
|
||||
})
|
||||
out[i] = gin.H{
|
||||
"month": m.Month,
|
||||
"requests": m.Requests,
|
||||
"input_tokens": m.InputTokens,
|
||||
"output_tokens": m.OutputTokens,
|
||||
"cache_read_tokens": m.CacheReadTokens,
|
||||
"cost": m.Cost,
|
||||
"models": modelList,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": gin.H{
|
||||
"year": year,
|
||||
"months": out,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// MyUsageLogs GET /api/usage/logs?page=1&pageSize=20 — 当前用户的用量明细(分页)。
|
||||
func (h *Handler) MyUsageLogs(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
|
||||
Reference in New Issue
Block a user