From 0628d5050f47c5480267b580a7a5142ade31ead6 Mon Sep 17 00:00:00 2001 From: Sakurasan <26715255+Sakurasan@users.noreply.github.com> Date: Wed, 2 Sep 2026 02:36:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=94=A8=E9=87=8F=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E9=A1=B5=E6=94=B9=E7=89=88=EF=BC=8C=E6=9C=88=E5=BA=A6=E6=B1=87?= =?UTF-8?q?=E6=80=BB=E6=94=AF=E6=8C=81=E5=A4=9A=E6=8C=87=E6=A0=87=E5=9B=BE?= =?UTF-8?q?=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 GET /api/usage/monthly?year= 年度按自然月聚合,每月含按模型分解(token 降序) - 月度汇总改为堆叠柱状图:Token/消费金额/调用次数三指标切换,按模型分色(图例取前 8,其余归入「其他」) - 选中月份概览卡片(金额/次数/token 分解),点击柱体或图例联动切换 - 年份切换、悬停明细 tooltip、请求明细保留 --- backend/internal/api/usage.go | 126 +++++++++ backend/router/setRouter.go | 1 + frontend/src/stores/usage.ts | 21 +- frontend/src/types/index.ts | 27 ++ frontend/src/views/dashboard/UsageStats.vue | 285 ++++++++++++++++---- 5 files changed, 407 insertions(+), 53 deletions(-) diff --git a/backend/internal/api/usage.go b/backend/internal/api/usage.go index e9e559f..ce715f0 100644 --- a/backend/internal/api/usage.go +++ b/backend/internal/api/usage.go @@ -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") diff --git a/backend/router/setRouter.go b/backend/router/setRouter.go index 54cee44..9ad9dbf 100644 --- a/backend/router/setRouter.go +++ b/backend/router/setRouter.go @@ -145,6 +145,7 @@ func SetRouter(cfg *config.Config, db *gorm.DB, web *embed.FS) { // 用户自身用量统计 apiGroup.GET("/usage/stats", apiHandler.MyUsageStats) + apiGroup.GET("/usage/monthly", apiHandler.MyUsageMonthly) apiGroup.GET("/usage/logs", apiHandler.MyUsageLogs) } diff --git a/frontend/src/stores/usage.ts b/frontend/src/stores/usage.ts index a7d3b65..6c94aab 100644 --- a/frontend/src/stores/usage.ts +++ b/frontend/src/stores/usage.ts @@ -2,7 +2,7 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import request from '@/api/client' -import type { UsageStatsData, UsageLogItem, AdminUsageSummary } from '@/types' +import type { UsageStatsData, UsageLogItem, AdminUsageSummary, MonthlyUsageData } from '@/types' export const useUsageStore = defineStore('usage', () => { const loading = ref(false) @@ -11,6 +11,9 @@ export const useUsageStore = defineStore('usage', () => { // 普通用户:每日统计 const stats = ref(null) + // 普通用户:年度按月统计(含按模型分解) + const monthly = ref(null) + // 普通用户:自身明细 const myLogs = ref([]) const myLogsTotal = ref(0) @@ -34,6 +37,20 @@ export const useUsageStore = defineStore('usage', () => { } } + async function fetchMonthly(year?: number) { + loading.value = true + error.value = null + try { + const res = await request.get('/usage/monthly', { params: year ? { year } : {} }) + monthly.value = res.data?.data ?? null + } catch (err: any) { + error.value = err.response?.data?.error || '获取月度统计失败' + throw err + } finally { + loading.value = false + } + } + async function fetchMyLogs(pageSize = 20, page = 1) { loading.value = true error.value = null @@ -82,12 +99,14 @@ export const useUsageStore = defineStore('usage', () => { loading, error, stats, + monthly, myLogs, myLogsTotal, adminLogs, adminLogsTotal, adminSummary, fetchStats, + fetchMonthly, fetchMyLogs, fetchAdminLogs, fetchAdminSummary, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 955d60e..f41f31a 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -200,6 +200,33 @@ export interface UsageStatsData { totals: UsageTotals } +// 月度按模型用量分解(柱状图分色堆叠用) +export interface MonthlyModelUsage { + model_id: number + model_name: string + requests: number + input_tokens: number + output_tokens: number + cache_read_tokens: number + cost: number +} + +// 单个自然月的聚合(models 已按 token 总量降序) +export interface MonthlyUsage { + month: string // "2026-09" + requests: number + input_tokens: number + output_tokens: number + cache_read_tokens: number + cost: number + models: MonthlyModelUsage[] +} + +export interface MonthlyUsageData { + year: number + months: MonthlyUsage[] +} + export interface UsageLogItem { id: number request_id?: string diff --git a/frontend/src/views/dashboard/UsageStats.vue b/frontend/src/views/dashboard/UsageStats.vue index 5be4840..c930114 100644 --- a/frontend/src/views/dashboard/UsageStats.vue +++ b/frontend/src/views/dashboard/UsageStats.vue @@ -2,55 +2,97 @@
-
+
加载中…