Compare commits

...
2 Commits
Author SHA1 Message Date
Sakurasan ca4dc4b3b7 fix: 缓存 token 计费按上游协议区分口径
- 新增 ComputeCost:OpenAI 系 prompt 含缓存读需扣减(异常数据钳制为 0);
  Anthropic input_tokens 不含缓存,按原值计费,缓存写按输入价 ×1.25(原实现误用输出价)
- recordUsage 传入上游协议(用量语义跟随解析它的上游响应,而非客户端协议)
- 补充单元测试覆盖两种协议口径与边界情况
2026-09-02 02:36:28 +08:00
Sakurasan 0628d5050f feat: 用量统计页改版,月度汇总支持多指标图表
- 新增 GET /api/usage/monthly?year= 年度按自然月聚合,每月含按模型分解(token 降序)
- 月度汇总改为堆叠柱状图:Token/消费金额/调用次数三指标切换,按模型分色(图例取前 8,其余归入「其他」)
- 选中月份概览卡片(金额/次数/token 分解),点击柱体或图例联动切换
- 年份切换、悬停明细 tooltip、请求明细保留
2026-09-02 02:36:28 +08:00
8 changed files with 523 additions and 65 deletions
+126
View File
@@ -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")
+29
View File
@@ -0,0 +1,29 @@
package proxy
import (
"opencatd-open/internal/proxy/convert"
)
// cacheWriteInputMultiplier 缓存写(cache creation)相对输入价的倍数。
// Anthropic 官方口径:缓存写按基础输入价的 1.25 倍计费(5m TTL);OpenAI 系无缓存写概念。
const cacheWriteInputMultiplier = 1.25
// ComputeCost 按上游协议的 token 语义计算一次请求的费用(USD)。
// 价格均为每百万 token 的 USD 单价。tok 的 token 语义由解析它的上游协议决定:
// - chat / responses(OpenAI 系):prompt_tokens 包含缓存读,
// 非缓存输入 = input − cacheRead;该协议没有缓存写,cacheCreation 恒为 0。
// - messages(Anthropic):input_tokens 不含缓存读/写(三个字段相互独立),
// 非缓存输入 = input 原值,不得再扣减;缓存写按输入价 ×1.25。
func ComputeCost(upstreamProto string, input, output, cacheRead, cacheCreation int, inputPrice, outputPrice, cacheReadPrice float64) float64 {
uncached := input
if upstreamProto != convert.ProtoMessages {
uncached -= cacheRead
if uncached < 0 {
uncached = 0
}
}
return (float64(uncached)*inputPrice +
float64(cacheRead)*cacheReadPrice +
float64(cacheCreation)*inputPrice*cacheWriteInputMultiplier +
float64(output)*outputPrice) / 1e6
}
+75
View File
@@ -0,0 +1,75 @@
package proxy
import "testing"
func TestComputeCost(t *testing.T) {
// 标准三价:输入 0.5 / 输出 1.5 / 缓存读 0.05($/M)
const (
inPrice = 0.5
outPrice = 1.5
cchPrice = 0.05
)
tests := []struct {
name string
upstreamProto string
input int
output int
cacheRead int
cacheCreation int
inP float64
outP float64
cchP float64
want float64
}{
{
// OpenAI:prompt 含缓存读,需扣减:(11000−10000)×0.5 + 10000×0.05 + 500×1.5
name: "openai prompt includes cache read",
upstreamProto: "chat",
input: 11000, output: 500, cacheRead: 10000,
inP: inPrice, outP: outPrice, cchP: cchPrice,
want: (1000*inPrice + 10000*cchPrice + 500*outPrice) / 1e6,
},
{
// Anthropic:input 不含缓存;缓存写按输入价 ×1.25
name: "anthropic cache write at 1.25x input price",
upstreamProto: "messages",
input: 1000, output: 500, cacheRead: 10000, cacheCreation: 2000,
inP: inPrice, outP: outPrice, cchP: cchPrice,
want: (1000*inPrice + 10000*cchPrice + 2000*inPrice*1.25 + 500*outPrice) / 1e6,
},
{
// Anthropic 口径不得扣减缓存读(否则这里非缓存输入会算成负数)
name: "anthropic does not subtract cache read",
upstreamProto: "messages",
input: 100, output: 0, cacheRead: 1000,
inP: inPrice, outP: outPrice, cchP: cchPrice,
want: (100*inPrice + 1000*cchPrice) / 1e6,
},
{
// OpenAI 异常数据:cached > prompt 时非缓存输入钳制为 0,不出现负费用
name: "openai clamps negative uncached input",
upstreamProto: "responses",
input: 100, output: 0, cacheRead: 5000,
inP: inPrice, outP: outPrice, cchP: cchPrice,
want: (5000 * cchPrice) / 1e6,
},
{
// 未配置价格时费用为 0
name: "no prices no cost",
upstreamProto: "messages",
input: 1000, output: 1000, cacheRead: 1000, cacheCreation: 1000,
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ComputeCost(tt.upstreamProto, tt.input, tt.output, tt.cacheRead, tt.cacheCreation, tt.inP, tt.outP, tt.cchP)
if diff := got - tt.want; diff > 1e-12 || diff < -1e-12 {
t.Fatalf("ComputeCost(%q, in=%d, out=%d, cr=%d, cw=%d) = %v, want %v",
tt.upstreamProto, tt.input, tt.output, tt.cacheRead, tt.cacheCreation, got, tt.want)
}
})
}
}
+12 -12
View File
@@ -204,7 +204,7 @@ func (g *Gateway) Dispatch(c *gin.Context, req *Request) {
g.writeError(c, http.StatusServiceUnavailable, "no enabled channels for model: "+req.Model) g.writeError(c, http.StatusServiceUnavailable, "no enabled channels for model: "+req.Model)
g.recordUsage(req, nil, nil, usage.Event{ g.recordUsage(req, nil, nil, usage.Event{
IsError: true, ErrorCode: "no_channel", IsError: true, ErrorCode: "no_channel",
}, convert.TokenUsage{}) }, convert.TokenUsage{}, "")
return return
} }
@@ -273,7 +273,7 @@ func (g *Gateway) Dispatch(c *gin.Context, req *Request) {
IsError: true, IsError: true,
ErrorCode: "upstream_error", ErrorCode: "upstream_error",
LatencyMS: int(time.Since(start).Milliseconds()), LatencyMS: int(time.Since(start).Milliseconds()),
}, convert.TokenUsage{}) }, convert.TokenUsage{}, targetFormat)
continue // 可重试:换下一个渠道 continue // 可重试:换下一个渠道
} }
@@ -289,7 +289,7 @@ func (g *Gateway) Dispatch(c *gin.Context, req *Request) {
IsError: true, IsError: true,
ErrorCode: fmt.Sprintf("upstream_%d", resp.StatusCode), ErrorCode: fmt.Sprintf("upstream_%d", resp.StatusCode),
LatencyMS: int(time.Since(start).Milliseconds()), LatencyMS: int(time.Since(start).Milliseconds()),
}, convert.TokenUsage{}) }, convert.TokenUsage{}, targetFormat)
// 429/5xx 可换渠道重试;4xx 直接透传 // 429/5xx 可换渠道重试;4xx 直接透传
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 { if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
lastErrStatus, lastErrBody = resp.StatusCode, string(body) lastErrStatus, lastErrBody = resp.StatusCode, string(body)
@@ -318,13 +318,13 @@ func (g *Gateway) Dispatch(c *gin.Context, req *Request) {
IsError: true, IsError: true,
ErrorCode: errCode, ErrorCode: errCode,
LatencyMS: int(time.Since(start).Milliseconds()), LatencyMS: int(time.Since(start).Milliseconds()),
}, tok) }, tok, targetFormat)
return return
} }
// 成功记录:用量 + 定价计费。 // 成功记录:用量 + 定价计费。
g.recordUsage(req, cand, ch, usage.Event{ g.recordUsage(req, cand, ch, usage.Event{
LatencyMS: int(time.Since(start).Milliseconds()), LatencyMS: int(time.Since(start).Milliseconds()),
}, tok) }, tok, targetFormat)
return return
} }
@@ -349,9 +349,10 @@ func rewriteModel(body []byte, upstreamModel string) []byte {
return out return out
} }
// recordUsage 汇总一次请求的用量事件并异步落库。tok 为从上游响应提取的用量。 // recordUsage 汇总一次请求的用量事件并异步落库。tok 为从上游响应提取的用量,
// 其 token 语义由 upstreamProto(渠道实际使用的上游协议)决定。
// cand/ch 可为 nil(无可用渠道的失败场景)。 // cand/ch 可为 nil(无可用渠道的失败场景)。
func (g *Gateway) recordUsage(req *Request, cand *channel.Candidate, ch *store.Channel, ev usage.Event, tok convert.TokenUsage) { func (g *Gateway) recordUsage(req *Request, cand *channel.Candidate, ch *store.Channel, ev usage.Event, tok convert.TokenUsage, upstreamProto string) {
if g.usageRec == nil { if g.usageRec == nil {
return return
} }
@@ -380,7 +381,8 @@ func (g *Gateway) recordUsage(req *Request, cand *channel.Candidate, ch *store.C
} }
} }
// 定价与成本(价格按每百万 token 的 USD 单价)。 // 定价与成本(价格按每百万 token 的 USD 单价)。
// 成本口径:非缓存输入 × 输入价 + 缓存读 × 缓存价 + 缓存写与输出 × 输出价。 // 成本口径按上游协议区分(详见 ComputeCost):OpenAI 系 prompt 含缓存读需扣减;
// Anthropic 的 input_tokens 不含缓存,缓存写按输入价 ×1.25。
if ev.ModelID != 0 { if ev.ModelID != 0 {
if m, err := g.modelDAO.GetByID(ev.ModelID); err == nil { if m, err := g.modelDAO.GetByID(ev.ModelID); err == nil {
ev.InputPrice = m.InputPrice ev.InputPrice = m.InputPrice
@@ -389,10 +391,8 @@ func (g *Gateway) recordUsage(req *Request, cand *channel.Candidate, ch *store.C
} }
} }
if !ev.IsError { if !ev.IsError {
ev.Cost = (float64(ev.PromptTokens-ev.CacheReadTokens)*ev.InputPrice + ev.Cost = ComputeCost(upstreamProto, tok.InputTokens, tok.OutputTokens, tok.CacheReadTokens, tok.CacheCreationTokens,
float64(ev.CacheReadTokens)*ev.CacheReadPrice + ev.InputPrice, ev.OutputPrice, ev.CacheReadPrice)
float64(ev.CacheCreationTokens)*ev.OutputPrice +
float64(ev.CompletionTokens)*ev.OutputPrice) / 1e6
} }
g.usageRec.Record(ev) g.usageRec.Record(ev)
} }
+1
View File
@@ -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)
} }
+20 -1
View File
@@ -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,
+27
View File
@@ -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
+233 -52
View File
@@ -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 }}
</button>
</div> </div>
<div v-if="barItems.length" class="flex h-40 items-end gap-1"> <span v-if="maxMetricValue > 0" class="text-xs text-base-content/40">峰值 {{ fmtMetricValue(maxMetricValue) }}</span>
<div v-for="b in barItems" :key="b.date" class="flex flex-1 flex-col items-center gap-1" :title="`${b.date}: ${b.requests} 次`">
<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>
<!-- 明细表格 --> <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 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>