🏁 Final commit: Project Token Usage Viewer completed

This commit is contained in:
2026-01-21 14:21:43 +08:00
parent b967deb4b1
commit a77fcdd3dc
24 changed files with 1087 additions and 651 deletions

View File

@@ -0,0 +1,291 @@
/**
* TokenUsageDashboard 组件
*
* 主仪表盘,展示 4 个账户的 claude-opus-4-5-thinking 配额使用情况
* 使用 OpenBridge TopBar + AlertTopbarElement
*/
import '@oicl/openbridge-webcomponents/dist/icons/icon-palette-day.js'
import '@oicl/openbridge-webcomponents/dist/icons/icon-palette-night.js'
import { AlertType } from '@oicl/openbridge-webcomponents/dist/types'
import { lazy, Suspense, useCallback, useMemo, useState } from 'react'
import { HealthRing } from '@/components/HealthRing'
import { useTheme } from '@/hooks/useTheme'
import type { ModelUsage } from '@/orpc/contracts/usage'
// 懒加载 OpenBridge 组件以避免 SSR 问题
const ObcTopBar = lazy(() =>
import(
'@oicl/openbridge-webcomponents-react/components/top-bar/top-bar'
).then((mod) => ({ default: mod.ObcTopBar })),
)
const ObcAlertTopbarElement = lazy(() =>
import(
'@oicl/openbridge-webcomponents-react/components/alert-topbar-element/alert-topbar-element'
).then((mod) => ({ default: mod.ObcAlertTopbarElement })),
)
const ObcNotificationMessageItem = lazy(() =>
import(
'@oicl/openbridge-webcomponents-react/components/notification-message-item/notification-message-item'
).then((mod) => ({ default: mod.ObcNotificationMessageItem })),
)
const ObcAlertIcon = lazy(() =>
import(
'@oicl/openbridge-webcomponents-react/components/alert-icon/alert-icon'
).then((mod) => ({ default: mod.ObcAlertIcon })),
)
const ObcNavigationMenu = lazy(() =>
import(
'@oicl/openbridge-webcomponents-react/components/navigation-menu/navigation-menu'
).then((mod) => ({ default: mod.ObcNavigationMenu })),
)
const ObcNavigationItem = lazy(() =>
import(
'@oicl/openbridge-webcomponents-react/components/navigation-item/navigation-item'
).then((mod) => ({ default: mod.ObcNavigationItem })),
)
export interface TokenUsageDashboardProps {
data: {
opusModels: ModelUsage[]
fetchedAt: string
}
}
/** 告警阈值 */
const ALERT_THRESHOLD = 0.2 // 20% 警戒线
const CRITICAL_THRESHOLD = 0.05 // 5% 紧急阈值
interface AlertInfo {
account: string
model: string
displayName?: string
remainingFraction: number
type: AlertType
}
/**
* 从账户名中提取用户名部分
* 例如: "antigravity-2220328339_qq" -> "2220328339_qq"
*/
const extractUsername = (account: string): string => {
// 移除 "antigravity-" 前缀
if (account.startsWith('antigravity-')) {
return account.slice('antigravity-'.length)
}
// 移除其他常见前缀
const prefixes = ['anthropic-', 'claude-', 'openai-']
for (const prefix of prefixes) {
if (account.startsWith(prefix)) {
return account.slice(prefix.length)
}
}
return account
}
/**
* 计算告警列表
*/
const getAlerts = (models: ModelUsage[]): AlertInfo[] => {
const alerts: AlertInfo[] = []
for (const model of models) {
if (model.remainingFraction < CRITICAL_THRESHOLD) {
alerts.push({
account: model.account,
model: model.model,
displayName: model.displayName,
remainingFraction: model.remainingFraction,
type: AlertType.Alarm,
})
} else if (model.remainingFraction < ALERT_THRESHOLD) {
alerts.push({
account: model.account,
model: model.model,
displayName: model.displayName,
remainingFraction: model.remainingFraction,
type: AlertType.Warning,
})
}
}
// 按严重程度排序Alarm 优先)
return alerts.sort((a, b) => {
if (a.type === AlertType.Alarm && b.type !== AlertType.Alarm) return -1
if (a.type !== AlertType.Alarm && b.type === AlertType.Alarm) return 1
return a.remainingFraction - b.remainingFraction
})
}
/**
* 获取最高告警类型
*/
const getHighestAlertType = (alerts: AlertInfo[]): AlertType => {
if (alerts.some((a) => a.type === AlertType.Alarm)) return AlertType.Alarm
if (alerts.some((a) => a.type === AlertType.Warning)) return AlertType.Warning
return AlertType.Caution
}
/**
* TopBar 占位符SSR 时显示)
*/
const TopBarFallback = () => (
<div className="h-14 bg-[var(--container-background-color)] border-b border-[var(--divider-color)]" />
)
export const TokenUsageDashboard = ({ data }: TokenUsageDashboardProps) => {
const { opusModels } = data
const { theme, setTheme } = useTheme()
const [alertMuted, setAlertMuted] = useState(false)
const [menuOpen, setMenuOpen] = useState(false)
// 计算告警
const alerts = useMemo(() => getAlerts(opusModels), [opusModels])
const alertType = getHighestAlertType(alerts)
// 处理静音点击
const handleMuteClick = useCallback(() => {
setAlertMuted((prev) => !prev)
}, [])
// 处理菜单按钮点击
const handleMenuButtonClick = useCallback(() => {
setMenuOpen((prev) => !prev)
}, [])
// 处理主题切换
const handleThemeChange = useCallback(
(newTheme: 'day' | 'night') => {
setTheme(newTheme)
setMenuOpen(false)
},
[setTheme],
)
return (
<div className="min-h-screen flex flex-col bg-[var(--container-background-color)] text-[var(--on-container-color)]">
{/* 顶部导航栏 */}
<header className="sticky top-0 z-50">
<Suspense fallback={<TopBarFallback />}>
<ObcTopBar
appTitle="Token Usage Viewer"
pageName=""
onMenuButtonClicked={
handleMenuButtonClick as unknown as EventListener
}
menuButtonActivated={menuOpen}
>
{/* 右侧: 告警面板 */}
<div slot="alerts">
<Suspense fallback={null}>
<ObcAlertTopbarElement
nAlerts={alerts.length}
alertType={alertType}
alertMuted={alertMuted}
showAck={false}
onMuteclick={handleMuteClick as unknown as EventListener}
>
{alerts.length > 0 && alerts[0] && (
<Suspense fallback={null}>
<ObcNotificationMessageItem time="">
<span slot="icon">
<Suspense fallback={null}>
<ObcAlertIcon
name={
alerts[0].type === AlertType.Alarm
? 'alarm-unack'
: 'warning-unack'
}
/>
</Suspense>
</span>
<span slot="message">
{extractUsername(alerts[0].account)}: {' '}
{Math.round(alerts[0].remainingFraction * 100)}%
</span>
</ObcNotificationMessageItem>
</Suspense>
)}
</ObcAlertTopbarElement>
</Suspense>
</div>
</ObcTopBar>
</Suspense>
</header>
{/* 侧边导航菜单 */}
{menuOpen && (
<aside className="fixed top-14 left-0 z-40 h-[calc(100vh-3.5rem)] w-64 bg-[var(--container-background-color)] border-r border-[var(--divider-color)] shadow-lg">
<Suspense fallback={null}>
<ObcNavigationMenu>
<div slot="main">
<Suspense fallback={null}>
<ObcNavigationItem
label="白天模式"
checked={theme === 'day'}
onClick={() => handleThemeChange('day')}
>
<span
slot="icon"
// biome-ignore lint: custom element
dangerouslySetInnerHTML={{
__html: '<obi-palette-day></obi-palette-day>',
}}
/>
</ObcNavigationItem>
</Suspense>
<Suspense fallback={null}>
<ObcNavigationItem
label="夜间模式"
checked={theme === 'night'}
onClick={() => handleThemeChange('night')}
>
<span
slot="icon"
// biome-ignore lint: custom element
dangerouslySetInnerHTML={{
__html: '<obi-palette-night></obi-palette-night>',
}}
/>
</ObcNavigationItem>
</Suspense>
</div>
</ObcNavigationMenu>
</Suspense>
</aside>
)}
{/* 点击遮罩关闭菜单 */}
{menuOpen && (
<button
type="button"
aria-label="关闭菜单"
className="fixed inset-0 z-30 bg-black/20 cursor-default"
onClick={() => setMenuOpen(false)}
onKeyDown={(e) => e.key === 'Escape' && setMenuOpen(false)}
/>
)}
{/* 主内容区 */}
<main className="flex-1 flex flex-col items-center justify-center p-8">
{/* 4 个圆环横向排列 */}
<div className="flex flex-wrap justify-center gap-10 lg:gap-16">
{opusModels.slice(0, 4).map((model) => (
<HealthRing
key={`${model.account}-${model.model}`}
account={extractUsername(model.account)}
displayName="Opus 4.5"
remainingFraction={model.remainingFraction}
resetTime={model.resetTime}
size={180}
/>
))}
</div>
</main>
</div>
)
}