feat: format token counts in compact k/M/B/T units
This commit is contained in:
+11
-1
@@ -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)]
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user