feat: 用量统计页改版,月度汇总支持多指标图表
- 新增 GET /api/usage/monthly?year= 年度按自然月聚合,每月含按模型分解(token 降序) - 月度汇总改为堆叠柱状图:Token/消费金额/调用次数三指标切换,按模型分色(图例取前 8,其余归入「其他」) - 选中月份概览卡片(金额/次数/token 分解),点击柱体或图例联动切换 - 年份切换、悬停明细 tooltip、请求明细保留
This commit is contained in:
@@ -1,7 +1,9 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"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 — 当前用户的用量明细(分页)。
|
// MyUsageLogs GET /api/usage/logs?page=1&pageSize=20 — 当前用户的用量明细(分页)。
|
||||||
func (h *Handler) MyUsageLogs(c *gin.Context) {
|
func (h *Handler) MyUsageLogs(c *gin.Context) {
|
||||||
userID, _ := c.Get("user_id")
|
userID, _ := c.Get("user_id")
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ func SetRouter(cfg *config.Config, db *gorm.DB, web *embed.FS) {
|
|||||||
|
|
||||||
// 用户自身用量统计
|
// 用户自身用量统计
|
||||||
apiGroup.GET("/usage/stats", apiHandler.MyUsageStats)
|
apiGroup.GET("/usage/stats", apiHandler.MyUsageStats)
|
||||||
|
apiGroup.GET("/usage/monthly", apiHandler.MyUsageMonthly)
|
||||||
apiGroup.GET("/usage/logs", apiHandler.MyUsageLogs)
|
apiGroup.GET("/usage/logs", apiHandler.MyUsageLogs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import request from '@/api/client'
|
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', () => {
|
export const useUsageStore = defineStore('usage', () => {
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -11,6 +11,9 @@ export const useUsageStore = defineStore('usage', () => {
|
|||||||
// 普通用户:每日统计
|
// 普通用户:每日统计
|
||||||
const stats = ref<UsageStatsData | null>(null)
|
const stats = ref<UsageStatsData | null>(null)
|
||||||
|
|
||||||
|
// 普通用户:年度按月统计(含按模型分解)
|
||||||
|
const monthly = ref<MonthlyUsageData | null>(null)
|
||||||
|
|
||||||
// 普通用户:自身明细
|
// 普通用户:自身明细
|
||||||
const myLogs = ref<UsageLogItem[]>([])
|
const myLogs = ref<UsageLogItem[]>([])
|
||||||
const myLogsTotal = ref(0)
|
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) {
|
async function fetchMyLogs(pageSize = 20, page = 1) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
@@ -82,12 +99,14 @@ export const useUsageStore = defineStore('usage', () => {
|
|||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
stats,
|
stats,
|
||||||
|
monthly,
|
||||||
myLogs,
|
myLogs,
|
||||||
myLogsTotal,
|
myLogsTotal,
|
||||||
adminLogs,
|
adminLogs,
|
||||||
adminLogsTotal,
|
adminLogsTotal,
|
||||||
adminSummary,
|
adminSummary,
|
||||||
fetchStats,
|
fetchStats,
|
||||||
|
fetchMonthly,
|
||||||
fetchMyLogs,
|
fetchMyLogs,
|
||||||
fetchAdminLogs,
|
fetchAdminLogs,
|
||||||
fetchAdminSummary,
|
fetchAdminSummary,
|
||||||
|
|||||||
@@ -200,6 +200,33 @@ export interface UsageStatsData {
|
|||||||
totals: UsageTotals
|
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 {
|
export interface UsageLogItem {
|
||||||
id: number
|
id: number
|
||||||
request_id?: string
|
request_id?: string
|
||||||
|
|||||||
@@ -2,55 +2,97 @@
|
|||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
<BreadcrumbHeader />
|
<BreadcrumbHeader />
|
||||||
|
|
||||||
<div v-if="store.loading && !store.stats" class="py-16 text-center text-sm text-base-content/50">
|
<div v-if="store.loading && !store.monthly" class="py-16 text-center text-sm text-base-content/50">
|
||||||
加载中…
|
加载中…
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- 统计卡片 -->
|
<!-- 年份切换 + 选中月份概览卡片 -->
|
||||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-5">
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button class="btn btn-ghost btn-square btn-sm" aria-label="上一年" :disabled="year <= 2000" @click="switchYear(-1)">
|
||||||
|
<ChevronLeft class="size-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<span class="min-w-16 text-center text-lg font-semibold tabular-nums">{{ year }}</span>
|
||||||
|
<button class="btn btn-ghost btn-square btn-sm" aria-label="下一年" :disabled="year >= currentYear" @click="switchYear(1)">
|
||||||
|
<ChevronRight class="size-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-base-content/50">{{ selectedMonthLabel }}用量概览</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
|
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
|
||||||
<p class="text-xs text-base-content/50">请求次数</p>
|
<p class="text-xs text-base-content/50">消费金额 (USD)</p>
|
||||||
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtInt(totals.requests) }}</p>
|
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtCost(selectedMonth?.cost) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
|
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
|
||||||
<p class="text-xs text-base-content/50">输入 Tokens</p>
|
<p class="text-xs text-base-content/50">调用次数</p>
|
||||||
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtInt(totals.input_tokens) }}</p>
|
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtInt(selectedMonth?.requests) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
|
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
|
||||||
<p class="text-xs text-base-content/50">输出 Tokens</p>
|
<p class="text-xs text-base-content/50">Token 消耗</p>
|
||||||
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtInt(totals.output_tokens) }}</p>
|
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtInt(monthTokens(selectedMonth)) }}</p>
|
||||||
</div>
|
<p class="mt-0.5 text-xs tabular-nums text-base-content/50">
|
||||||
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
|
输入 {{ fmtCompact(selectedMonth?.input_tokens) }} · 输出 {{ fmtCompact(selectedMonth?.output_tokens) }} · 缓存 {{
|
||||||
<p class="text-xs text-base-content/50">缓存 Tokens</p>
|
fmtCompact(selectedMonth?.cache_read_tokens) }}
|
||||||
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtInt(totals.cache_read_tokens) }}</p>
|
</p>
|
||||||
</div>
|
|
||||||
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
|
|
||||||
<p class="text-xs text-base-content/50">费用 (USD)</p>
|
|
||||||
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtCost(totals.cost) }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 每日请求量条形图(纯 CSS) -->
|
<!-- 月度汇总图表:三种指标均按模型分色堆叠 -->
|
||||||
<div class="card border border-base-300/60 bg-base-100 p-5 shadow-sm">
|
<div class="card border border-base-300/60 bg-base-100 p-5 shadow-sm">
|
||||||
<div class="mb-4 flex items-center justify-between">
|
<div class="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||||
<h3 class="text-sm font-semibold">每日请求量</h3>
|
<h3 class="text-sm font-semibold">月度汇总</h3>
|
||||||
<select v-model="days" class="select select-sm border-base-300 bg-base-100" @change="loadStats">
|
<div class="flex items-center gap-3">
|
||||||
<option :value="7">近 7 天</option>
|
<div class="join">
|
||||||
<option :value="30">近 30 天</option>
|
<button v-for="opt in METRICS" :key="opt.key" class="btn btn-xs join-item"
|
||||||
<option :value="90">近 90 天</option>
|
:class="metric === opt.key ? 'btn-primary' : 'btn-ghost border-base-300/60'" @click="metric = opt.key">
|
||||||
</select>
|
{{ opt.label }}
|
||||||
</div>
|
</button>
|
||||||
<div v-if="barItems.length" class="flex h-40 items-end gap-1">
|
</div>
|
||||||
<div v-for="b in barItems" :key="b.date" class="flex flex-1 flex-col items-center gap-1" :title="`${b.date}: ${b.requests} 次`">
|
<span v-if="maxMetricValue > 0" class="text-xs text-base-content/40">峰值 {{ fmtMetricValue(maxMetricValue) }}</span>
|
||||||
<div class="w-full rounded-t bg-primary/70 transition-all" :style="{ height: b.height + 'px' }"></div>
|
|
||||||
<span class="text-[9px] leading-none text-base-content/40">{{ b.label }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="py-10 text-center text-sm text-base-content/50">暂无用量数据</div>
|
|
||||||
|
<div v-if="maxMetricValue > 0" class="flex h-44 items-end gap-1.5 sm:gap-3">
|
||||||
|
<div v-for="(m, i) in months" :key="m.month"
|
||||||
|
class="group flex h-full min-w-0 flex-1 cursor-pointer flex-col items-center justify-end gap-1"
|
||||||
|
:title="barTitle(m)" @click="selectedMonthIndex = i">
|
||||||
|
<!-- 柱顶总量 -->
|
||||||
|
<span class="text-[9px] leading-none tabular-nums text-base-content/40"
|
||||||
|
:class="{ 'font-semibold text-base-content/70': i === selectedMonthIndex }">
|
||||||
|
{{ metricValue(m) > 0 ? fmtMetricValue(metricValue(m)) : '' }}
|
||||||
|
</span>
|
||||||
|
<!-- 堆叠柱体:图例顺序堆叠,用量最大的模型在底部 -->
|
||||||
|
<div class="flex w-full max-w-10 flex-col-reverse overflow-hidden rounded-t transition-opacity"
|
||||||
|
:class="i === selectedMonthIndex ? 'opacity-100 ring-2 ring-primary/60' : 'opacity-80 group-hover:opacity-100'"
|
||||||
|
:style="{ height: barHeightPct(m) }">
|
||||||
|
<div v-for="seg in barSegments(m)" :key="seg.name" class="w-full"
|
||||||
|
:style="{ height: seg.pct + '%', backgroundColor: seg.color }"
|
||||||
|
:title="`${seg.name}: ${fmtMetricValue(seg.value)}(${seg.share}%)`">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="text-[10px] leading-none tabular-nums"
|
||||||
|
:class="i === selectedMonthIndex ? 'font-semibold text-primary' : 'text-base-content/50'">
|
||||||
|
{{ i + 1 }}月
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="py-10 text-center text-sm text-base-content/50">{{ year }} 年暂无用量数据</div>
|
||||||
|
|
||||||
|
<!-- 图例 -->
|
||||||
|
<div v-if="legend.length" class="mt-4 flex flex-wrap items-center gap-x-4 gap-y-1.5">
|
||||||
|
<span v-for="item in legend" :key="item.name" class="flex items-center gap-1.5 text-xs text-base-content/70"
|
||||||
|
:title="`${item.name}:全年 ${fmtMetricValue(item.value)}`">
|
||||||
|
<span class="size-2.5 rounded-sm" :style="{ backgroundColor: item.color }" aria-hidden="true"></span>
|
||||||
|
<span class="max-w-40 truncate">{{ item.name }}</span>
|
||||||
|
<span class="tabular-nums text-base-content/40">{{ fmtMetricValue(item.value) }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 明细表格 -->
|
<!-- 请求明细 -->
|
||||||
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||||
<div class="flex items-center justify-between px-5 pt-4">
|
<div class="flex items-center justify-between px-5 pt-4">
|
||||||
<h3 class="text-sm font-semibold">请求明细</h3>
|
<h3 class="text-sm font-semibold">请求明细</h3>
|
||||||
@@ -99,35 +141,151 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { ChevronLeft, ChevronRight } from '@lucide/vue'
|
||||||
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue'
|
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue'
|
||||||
import Pagination from '@/components/common/Pagination.vue'
|
import Pagination from '@/components/common/Pagination.vue'
|
||||||
import { useUsageStore } from '@/stores/usage'
|
import { useUsageStore } from '@/stores/usage'
|
||||||
|
import type { MonthlyUsage, MonthlyModelUsage } from '@/types'
|
||||||
|
|
||||||
const store = useUsageStore()
|
const store = useUsageStore()
|
||||||
|
|
||||||
const days = ref(30)
|
const currentYear = new Date().getFullYear()
|
||||||
const page = ref(1)
|
const year = ref(currentYear)
|
||||||
const pageSize = ref(20)
|
const selectedMonthIndex = ref(new Date().getMonth())
|
||||||
const myLogsTotal = computed(() => store.myLogsTotal)
|
|
||||||
const totals = computed(() => store.stats?.totals ?? {
|
|
||||||
requests: 0, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cost: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const barItems = computed(() => {
|
const months = computed<MonthlyUsage[]>(() => {
|
||||||
const stats = store.stats
|
const data = store.monthly
|
||||||
if (!stats) return []
|
if (data && data.year === year.value) return data.months
|
||||||
const max = Math.max(1, ...stats.dates.map(d => stats.daily[d]?.requests ?? 0))
|
// 数据未就绪/年份不匹配时给出 12 个月空骨架,保持布局稳定
|
||||||
return stats.dates.slice(-14).map(d => ({
|
return Array.from({ length: 12 }, (_, i) => ({
|
||||||
date: d,
|
month: `${year.value}-${String(i + 1).padStart(2, '0')}`,
|
||||||
requests: stats.daily[d]?.requests ?? 0,
|
requests: 0, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cost: 0, models: [],
|
||||||
height: Math.round(((stats.daily[d]?.requests ?? 0) / max) * 120),
|
|
||||||
label: d.slice(5).replace('-', '/'),
|
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadStats() {
|
const selectedMonth = computed(() => months.value[selectedMonthIndex.value])
|
||||||
|
const selectedMonthLabel = computed(() => `${year.value} 年 ${selectedMonthIndex.value + 1} 月`)
|
||||||
|
|
||||||
|
function monthTokens(m?: MonthlyUsage): number {
|
||||||
|
if (!m) return 0
|
||||||
|
return m.input_tokens + m.output_tokens + m.cache_read_tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 月度汇总图表:指标切换 + 按模型分色堆叠 ---
|
||||||
|
|
||||||
|
type MetricKey = 'tokens' | 'cost' | 'requests'
|
||||||
|
const METRICS: { key: MetricKey; label: string }[] = [
|
||||||
|
{ key: 'tokens', label: 'Token' },
|
||||||
|
{ key: 'cost', label: '消费金额' },
|
||||||
|
{ key: 'requests', label: '调用次数' },
|
||||||
|
]
|
||||||
|
const metric = ref<MetricKey>('tokens')
|
||||||
|
|
||||||
|
const PALETTE = [
|
||||||
|
'#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
|
||||||
|
'#14b8a6', '#f97316', '#3b82f6', '#ec4899', '#84cc16', '#eab308',
|
||||||
|
]
|
||||||
|
const OTHER_COLOR = '#94a3b8'
|
||||||
|
const MAX_LEGEND = 8 // 图例最多展示 8 个模型,其余归入「其他」
|
||||||
|
const OTHER_NAME = '其他'
|
||||||
|
|
||||||
|
function monthTokensOf(mm: MonthlyModelUsage): number {
|
||||||
|
return mm.input_tokens + mm.output_tokens + mm.cache_read_tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前指标下的数值(柱高、图例、峰值共用)
|
||||||
|
function metricValue(m: MonthlyUsage): number {
|
||||||
|
switch (metric.value) {
|
||||||
|
case 'cost': return m.cost
|
||||||
|
case 'requests': return m.requests
|
||||||
|
default: return monthTokens(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function metricValueOf(mm: MonthlyModelUsage): number {
|
||||||
|
switch (metric.value) {
|
||||||
|
case 'cost': return mm.cost
|
||||||
|
case 'requests': return mm.requests
|
||||||
|
default: return monthTokensOf(mm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const maxMetricValue = computed(() => Math.max(0, ...months.value.map(metricValue)))
|
||||||
|
|
||||||
|
// 全年维度统计每个模型在当前指标下的总量,取前 MAX_LEGEND 个进入图例
|
||||||
|
const legend = computed(() => {
|
||||||
|
const totals = new Map<string, number>()
|
||||||
|
for (const m of months.value) {
|
||||||
|
for (const mm of m.models) {
|
||||||
|
totals.set(mm.model_name, (totals.get(mm.model_name) ?? 0) + metricValueOf(mm))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sorted = [...totals.entries()].sort((a, b) => b[1] - a[1])
|
||||||
|
const top = sorted.slice(0, MAX_LEGEND).map(([name, value], i) => ({
|
||||||
|
name, value, color: PALETTE[i % PALETTE.length],
|
||||||
|
}))
|
||||||
|
const restValue = sorted.slice(MAX_LEGEND).reduce((s, [, v]) => s + v, 0)
|
||||||
|
if (restValue > 0) top.push({ name: OTHER_NAME, value: restValue, color: OTHER_COLOR })
|
||||||
|
return top
|
||||||
|
})
|
||||||
|
|
||||||
|
const legendIndex = computed(() => {
|
||||||
|
const idx = new Map<string, number>()
|
||||||
|
legend.value.forEach((item, i) => idx.set(item.name, i))
|
||||||
|
return idx
|
||||||
|
})
|
||||||
|
|
||||||
|
// 单月柱体:按图例顺序堆叠(保持各月颜色顺序一致),未进图例的模型归入「其他」
|
||||||
|
function barSegments(m: MonthlyUsage) {
|
||||||
|
const total = metricValue(m)
|
||||||
|
if (total === 0) return []
|
||||||
|
const byName = new Map<string, number>()
|
||||||
|
for (const mm of m.models) byName.set(mm.model_name, metricValueOf(mm))
|
||||||
|
|
||||||
|
const segs: { name: string; value: number; pct: number; share: number; color: string }[] = []
|
||||||
|
let other = 0
|
||||||
|
for (const [name, value] of byName) {
|
||||||
|
if (legendIndex.value.has(name)) continue
|
||||||
|
other += value
|
||||||
|
}
|
||||||
|
for (const item of legend.value) {
|
||||||
|
const value = item.name === OTHER_NAME ? other : (byName.get(item.name) ?? 0)
|
||||||
|
if (value <= 0) continue
|
||||||
|
const pct = (value / total) * 100
|
||||||
|
segs.push({ name: item.name, value, pct, share: Math.round(pct), color: item.color })
|
||||||
|
}
|
||||||
|
return segs
|
||||||
|
}
|
||||||
|
|
||||||
|
function barHeightPct(m: MonthlyUsage): string {
|
||||||
|
if (maxMetricValue.value === 0) return '0%'
|
||||||
|
return `${(metricValue(m) / maxMetricValue.value) * 100}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
function barTitle(m: MonthlyUsage): string {
|
||||||
|
if (monthTokens(m) === 0 && m.requests === 0) return `${m.month}:无用量`
|
||||||
|
const parts = m.models
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => metricValueOf(b) - metricValueOf(a))
|
||||||
|
.map(mm => `${mm.model_name} ${fmtMetricValue(metricValueOf(mm))}`)
|
||||||
|
return `${m.month}:${fmtInt(m.requests)} 次调用,${fmtInt(monthTokens(m))} tokens,${fmtCost(m.cost)}\n${parts.join('\n')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const switchYear = (delta: number) => {
|
||||||
|
const next = year.value + delta
|
||||||
|
if (next < 2000 || next > currentYear) return
|
||||||
|
year.value = next
|
||||||
|
selectedMonthIndex.value = next === currentYear ? new Date().getMonth() : 11
|
||||||
|
loadMonthly()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 请求明细(保留原有功能) ---
|
||||||
|
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const myLogsTotal = computed(() => store.myLogsTotal)
|
||||||
|
|
||||||
|
async function loadMonthly() {
|
||||||
try {
|
try {
|
||||||
await store.fetchStats(days.value)
|
await store.fetchMonthly(year.value)
|
||||||
} catch { /* toast 由 store 抛错,页面保持静默 */ }
|
} catch { /* toast 由 store 抛错,页面保持静默 */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +305,30 @@ function fmtInt(n?: number): string {
|
|||||||
return (n ?? 0).toLocaleString()
|
return (n ?? 0).toLocaleString()
|
||||||
}
|
}
|
||||||
function fmtCost(n?: number): string {
|
function fmtCost(n?: number): string {
|
||||||
return `$${(n ?? 0).toFixed(6)}`
|
return `$${(n ?? 0).toFixed(4)}`
|
||||||
|
}
|
||||||
|
// 金额紧凑格式(图表柱顶/图例使用)
|
||||||
|
function fmtMoney(v: number): string {
|
||||||
|
if (v >= 1e6) return '$' + (v / 1e6).toFixed(2) + 'M'
|
||||||
|
if (v >= 1e3) return '$' + (v / 1e3).toFixed(2) + 'k'
|
||||||
|
if (v >= 1) return '$' + v.toFixed(2)
|
||||||
|
return '$' + v.toFixed(4)
|
||||||
|
}
|
||||||
|
// 当前指标数值格式化
|
||||||
|
function fmtMetricValue(v: number): string {
|
||||||
|
switch (metric.value) {
|
||||||
|
case 'cost': return fmtMoney(v)
|
||||||
|
case 'requests': return fmtCompact(v)
|
||||||
|
default: return fmtCompact(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 紧凑数字:柱顶/图例等小空间使用
|
||||||
|
function fmtCompact(n?: number): string {
|
||||||
|
const v = n ?? 0
|
||||||
|
if (v >= 1e9) return (v / 1e9).toFixed(1) + 'B'
|
||||||
|
if (v >= 1e6) return (v / 1e6).toFixed(1) + 'M'
|
||||||
|
if (v >= 1e3) return (v / 1e3).toFixed(1) + 'k'
|
||||||
|
return String(v)
|
||||||
}
|
}
|
||||||
function fmtTime(t?: string): string {
|
function fmtTime(t?: string): string {
|
||||||
if (!t) return '—'
|
if (!t) return '—'
|
||||||
@@ -174,7 +355,7 @@ function statusClass(s: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadStats()
|
loadMonthly()
|
||||||
loadLogs()
|
loadLogs()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user