feat: format token counts in compact k/M/B/T units

This commit is contained in:
2026-10-25 15:16:34 +08:00
parent 778bce77d0
commit 293f7f524a
5 changed files with 21 additions and 15 deletions
+11 -1
View File
@@ -25,6 +25,16 @@ export type Action = (fn: () => Promise<void>) => void
export const num = (v: number | null | undefined) => (v == null ? '—' : v.toLocaleString('zh-CN')) export const num = (v: number | null | undefined) => (v == null ? '—' : v.toLocaleString('zh-CN'))
/** 紧凑 tokens 换算:1.2k / 3.4M / 5.6B / 7.8T,保留至多一位小数;空值显示 —。 */
export const compact = (v: number | null | undefined) => {
if (v == null) return '—'
if (v >= 1e12) return (v / 1e12).toFixed(1).replace(/\.0$/, '') + 'T'
if (v >= 1e9) return (v / 1e9).toFixed(1).replace(/\.0$/, '') + 'B'
if (v >= 1e6) return (v / 1e6).toFixed(1).replace(/\.0$/, '') + 'M'
if (v >= 1e3) return (v / 1e3).toFixed(1).replace(/\.0$/, '') + 'k'
return num(v)
}
export function channelName(channels: Channel[], id: string) { export function channelName(channels: Channel[], id: string) {
return channels.find(c => c.id === id)?.name || id return channels.find(c => c.id === id)?.name || id
} }
@@ -35,4 +45,4 @@ export const usageHeads = ['请求数', '输入 tokens', '输出 tokens', '缓
export const cacheRate = (u: Usage) => (u.input_tokens ? num(Math.round((u.cache_read_tokens / u.input_tokens) * 1000) / 10) + '%' : '—') export const cacheRate = (u: Usage) => (u.input_tokens ? num(Math.round((u.cache_read_tokens / u.input_tokens) * 1000) / 10) + '%' : '—')
export const usageCells = (u: Usage) => export const usageCells = (u: Usage) =>
[u.requests, u.input_tokens, u.output_tokens, u.cache_read_tokens, u.cache_write_tokens].map(num).concat(cacheRate(u)) [num(u.requests), compact(u.input_tokens), compact(u.output_tokens), compact(u.cache_read_tokens), compact(u.cache_write_tokens), cacheRate(u)]
+2 -8
View File
@@ -1,6 +1,6 @@
import { useLayoutEffect, useRef, useState } from 'react' import { useLayoutEffect, useRef, useState } from 'react'
import type { Usage } from '../api' import type { Usage } from '../api'
import { num } from '../common' import { compact, num } from '../common'
/** /**
* 每日用量堆叠柱状图(纯 CSS 实现,无第三方依赖)。 * 每日用量堆叠柱状图(纯 CSS 实现,无第三方依赖)。
@@ -42,7 +42,7 @@ export function UsageChart({ data }: { data: (Usage & { day: string })[] }) {
{data.map((d, i) => { {data.map((d, i) => {
const total = totals[i] const total = totals[i]
return ( return (
<div className="chart-col" key={d.day} title={`${d.day}\n输入 ${num(d.input_tokens)} · 输出 ${num(d.output_tokens)}\n请求 ${num(d.requests)} 次`}> <div className="chart-col" key={d.day} title={`${d.day}\n输入 ${compact(d.input_tokens)} · 输出 ${compact(d.output_tokens)}\n请求 ${num(d.requests)} 次`}>
<div className="chart-bar"> <div className="chart-bar">
<i className="seg-output" style={{ height: `${(d.output_tokens / max) * 100}%` }} /> <i className="seg-output" style={{ height: `${(d.output_tokens / max) * 100}%` }} />
<i className="seg-input" style={{ height: `${(d.input_tokens / max) * 100}%` }} /> <i className="seg-input" style={{ height: `${(d.input_tokens / max) * 100}%` }} />
@@ -60,9 +60,3 @@ export function UsageChart({ data }: { data: (Usage & { day: string })[] }) {
</div> </div>
) )
} }
function compact(v: number): string {
if (v >= 1e6) return (v / 1e6).toFixed(1).replace(/\.0$/, '') + 'M'
if (v >= 1e3) return (v / 1e3).toFixed(1).replace(/\.0$/, '') + 'k'
return String(v)
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { useState, type FormEvent } from 'react' import { useState, type FormEvent } from 'react'
import { api, type Channel, type Key, type Stats } from '../api' import { api, type Channel, type Key, type Stats } from '../api'
import { channelName, num, type Action } from '../common' import { channelName, compact, num, type Action } from '../common'
import { formatTime, keyExpiry } from '../time' import { formatTime, keyExpiry } from '../time'
import { IconPlus } from '../components/icons' import { IconPlus } from '../components/icons'
import { Badge, Dialog, Feedback, Table } from '../components/ui' import { Badge, Dialog, Feedback, Table } from '../components/ui'
@@ -104,7 +104,7 @@ export function KeysPage({ keys, stats, channels, timezone, busy, error, notice,
<><button className="link" onClick={() => onViewStats(k)}>{k.name}</button><code className="sub">{k.prefix}…</code></>, <><button className="link" onClick={() => onViewStats(k)}>{k.name}</button><code className="sub">{k.prefix}…</code></>,
k.all_channels ? '全部渠道' : k.channels?.map(id => channelName(channels, id)).join('、') || '无渠道权限', k.all_channels ? '全部渠道' : k.channels?.map(id => channelName(channels, id)).join('、') || '无渠道权限',
<Badge variant={k.revoked ? 'danger' : k.enabled ? 'good' : 'neutral'}>{k.revoked ? '已撤销' : k.enabled ? '启用' : '停用'}</Badge>, <Badge variant={k.revoked ? 'danger' : k.enabled ? 'good' : 'neutral'}>{k.revoked ? '已撤销' : k.enabled ? '启用' : '停用'}</Badge>,
<>{num(u?.requests ?? 0)} 次<small className="sub">输入 {num(u?.input_tokens ?? 0)} / 输出 {num(u?.output_tokens ?? 0)}</small></>, <>{num(u?.requests ?? 0)} 次<small className="sub">输入 {compact(u?.input_tokens ?? 0)} / 输出 {compact(u?.output_tokens ?? 0)}</small></>,
<>{time(k.expires_at) === '—' ? '永不过期' : time(k.expires_at)}<small className="sub">创建于 {time(k.created_at)}</small></>, <>{time(k.expires_at) === '—' ? '永不过期' : time(k.expires_at)}<small className="sub">创建于 {time(k.created_at)}</small></>,
<div className="actions"> <div className="actions">
<button disabled={busy || k.revoked} onClick={() => toggle(k)}>{k.enabled ? '停用' : '启用'}</button> <button disabled={busy || k.revoked} onClick={() => toggle(k)}>{k.enabled ? '停用' : '启用'}</button>
+2 -2
View File
@@ -1,5 +1,5 @@
import type { Channel, Log } from '../api' import type { Channel, Log } from '../api'
import { channelName, num } from '../common' import { channelName, compact, num } from '../common'
import { formatTime } from '../time' import { formatTime } from '../time'
import { Badge, Table } from '../components/ui' import { Badge, Table } from '../components/ui'
@@ -18,7 +18,7 @@ export function LogsPage({ logs, channels }: { logs: Log[]; channels: Channel[]
<>{l.downstream} → {l.upstream}<small className="sub">{l.stream ? '流式' : '非流式'}</small></>, <>{l.downstream} → {l.upstream}<small className="sub">{l.stream ? '流式' : '非流式'}</small></>,
<><Badge variant={l.status >= 200 && l.status < 400 ? 'good' : 'danger'}>{l.status || '失败'}</Badge><small className="sub">{num(l.duration_ms)} ms</small></>, <><Badge variant={l.status >= 200 && l.status < 400 ? 'good' : 'danger'}>{l.status || '失败'}</Badge><small className="sub">{num(l.duration_ms)} ms</small></>,
l.usage_known l.usage_known
? <>输入 {num(l.input_tokens)} / 输出 {num(l.output_tokens)}<small className="sub">缓存读 {num(l.cache_read_tokens)} / 写 {num(l.cache_write_tokens)}</small></> ? <>输入 {compact(l.input_tokens)} / 输出 {compact(l.output_tokens)}<small className="sub">缓存读 {compact(l.cache_read_tokens)} / 写 {compact(l.cache_write_tokens)}</small></>
: '用量未知', : '用量未知',
<details> <details>
<summary>展开</summary> <summary>展开</summary>
+4 -2
View File
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import type { Channel, Stats } from '../api' import type { Channel, Stats } from '../api'
import { channelName, num, usageCells, usageHeads } from '../common' import { channelName, compact, num, usageCells, usageHeads } from '../common'
import { Table } from '../components/ui' import { Table } from '../components/ui'
import { UsageChart } from '../components/UsageChart' import { UsageChart } from '../components/UsageChart'
import { IconActivity, IconArrowDown, IconArrowUp, IconCheckCircle, IconDatabase, IconHelpCircle, IconXCircle, IconZap } from '../components/icons' import { IconActivity, IconArrowDown, IconArrowUp, IconCheckCircle, IconDatabase, IconHelpCircle, IconXCircle, IconZap } from '../components/icons'
@@ -16,6 +16,8 @@ const METRICS: { key: keyof Stats['summary']; label: string; icon: ReactNode; to
{ key: 'cache_write_tokens', label: '缓存写入', icon: <IconDatabase /> }, { key: 'cache_write_tokens', label: '缓存写入', icon: <IconDatabase /> },
] ]
const TOKEN_KEYS: (keyof Stats['summary'])[] = ['input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_write_tokens']
export function Overview({ stats, channels }: { stats: Stats | null; channels: Channel[] }) { export function Overview({ stats, channels }: { stats: Stats | null; channels: Channel[] }) {
if (!stats) return <div className="panel empty">暂无统计数据,请刷新重试。</div> if (!stats) return <div className="panel empty">暂无统计数据,请刷新重试。</div>
@@ -29,7 +31,7 @@ export function Overview({ stats, channels }: { stats: Stats | null; channels: C
<div className={`metric${alert ? ' alert' : ''}`} key={m.key}> <div className={`metric${alert ? ' alert' : ''}`} key={m.key}>
<span>{m.label}</span> <span>{m.label}</span>
<span className="metric-icon" aria-hidden="true">{m.icon}</span> <span className="metric-icon" aria-hidden="true">{m.icon}</span>
<strong>{num(value)}</strong> <strong>{TOKEN_KEYS.includes(m.key) ? compact(value) : num(value)}</strong>
</div> </div>
) )
})} })}