feat: 用量统计页改版,月度汇总支持多指标图表

- 新增 GET /api/usage/monthly?year= 年度按自然月聚合,每月含按模型分解(token 降序)
- 月度汇总改为堆叠柱状图:Token/消费金额/调用次数三指标切换,按模型分色(图例取前 8,其余归入「其他」)
- 选中月份概览卡片(金额/次数/token 分解),点击柱体或图例联动切换
- 年份切换、悬停明细 tooltip、请求明细保留
This commit is contained in:
Sakurasan
2026-09-02 02:36:28 +08:00
parent a376ac0722
commit 0628d5050f
5 changed files with 407 additions and 53 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")
+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 }}
</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>