- 使用 shadcn/ui 重新实现 TopBar、ThemeSidebar、AlertBadge 组件 - 解决 @oicl/openbridge-webcomponents ESM 模块解析问题 - 添加 OpenBridge 四种主题 CSS 变量 (day/bright/dusk/night) - Night 主题使用暗黄色文字保护夜视能力 - 更新 API 端点适配新的按模型分组数据结构
220 lines
6.4 KiB
TypeScript
220 lines
6.4 KiB
TypeScript
/**
|
||
* TokenUsageDashboard 组件
|
||
*
|
||
* 主仪表盘,展示多个账户的 claude-opus-4-5-thinking 配额使用情况。
|
||
* 使用自定义组件替代 OpenBridge 组件,基于 shadcn/ui 实现。
|
||
*
|
||
* 特性:
|
||
* - 多账户配额可视化 (根据 API 返回的账户数量动态显示)
|
||
* - 实时告警通知 (低于 20% 警告,低于 5% 紧急)
|
||
* - 支持四种主题切换 (day/bright/dusk/night)
|
||
*/
|
||
import { useCallback, useMemo, useState } from 'react'
|
||
import { HealthRing } from '@/components/HealthRing'
|
||
import {
|
||
AlertBadge,
|
||
AlertNotification,
|
||
AlertType,
|
||
} from '@/components/ui/AlertBadge'
|
||
import { ThemeSidebar } from '@/components/ui/ThemeSidebar'
|
||
import { TopBar } from '@/components/ui/TopBar'
|
||
import { useTheme } from '@/hooks/useTheme'
|
||
import type { ModelUsage } from '@/orpc/contracts/usage'
|
||
|
||
// ============================================================================
|
||
// 类型定义
|
||
// ============================================================================
|
||
|
||
export interface TokenUsageDashboardProps {
|
||
/** 从 API 获取的使用量数据 */
|
||
data: {
|
||
opusModels: ModelUsage[]
|
||
fetchedAt: string
|
||
}
|
||
}
|
||
|
||
/** 告警信息类型 */
|
||
interface AlertInfo {
|
||
account: string
|
||
model: string
|
||
displayName?: string
|
||
remainingFraction: number
|
||
type: AlertType
|
||
}
|
||
|
||
// ============================================================================
|
||
// 常量配置
|
||
// ============================================================================
|
||
|
||
/** 告警阈值配置 */
|
||
const ALERT_THRESHOLD = 0.2 // 20% - 警告阈值
|
||
const CRITICAL_THRESHOLD = 0.05 // 5% - 紧急阈值
|
||
|
||
/** 已知的账户前缀列表 */
|
||
const KNOWN_PREFIXES = [
|
||
'antigravity-',
|
||
'anthropic-',
|
||
'claude-',
|
||
'openai-',
|
||
] as const
|
||
|
||
// ============================================================================
|
||
// 工具函数
|
||
// ============================================================================
|
||
|
||
/**
|
||
* 从账户名中提取用户名部分
|
||
*
|
||
* @param account - 完整账户名 (如 "antigravity-2220328339_qq")
|
||
* @returns 提取后的用户名 (如 "2220328339_qq")
|
||
*/
|
||
const extractUsername = (account: string): string => {
|
||
for (const prefix of KNOWN_PREFIXES) {
|
||
if (account.startsWith(prefix)) {
|
||
return account.slice(prefix.length)
|
||
}
|
||
}
|
||
return account
|
||
}
|
||
|
||
/**
|
||
* 计算告警列表
|
||
*
|
||
* 根据配额剩余比例生成告警列表,并按严重程度排序。
|
||
*
|
||
* @param models - 模型使用量列表
|
||
* @returns 排序后的告警列表 (Alarm 优先)
|
||
*/
|
||
const computeAlerts = (models: ModelUsage[]): AlertInfo[] => {
|
||
const alerts: AlertInfo[] = []
|
||
|
||
for (const model of models) {
|
||
// 低于 5% 为紧急告警
|
||
if (model.remainingFraction < CRITICAL_THRESHOLD) {
|
||
alerts.push({
|
||
account: model.account,
|
||
model: model.model,
|
||
displayName: model.displayName,
|
||
remainingFraction: model.remainingFraction,
|
||
type: AlertType.Alarm,
|
||
})
|
||
}
|
||
// 低于 20% 为警告
|
||
else if (model.remainingFraction < ALERT_THRESHOLD) {
|
||
alerts.push({
|
||
account: model.account,
|
||
model: model.model,
|
||
displayName: model.displayName,
|
||
remainingFraction: model.remainingFraction,
|
||
type: AlertType.Warning,
|
||
})
|
||
}
|
||
}
|
||
|
||
// 按严重程度排序: Alarm > Warning,相同级别按剩余配额升序
|
||
return alerts.sort((a, b) => {
|
||
if (a.type !== b.type) {
|
||
return a.type === AlertType.Alarm ? -1 : 1
|
||
}
|
||
return a.remainingFraction - b.remainingFraction
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 获取最高级别的告警类型
|
||
*
|
||
* @param alerts - 告警列表
|
||
* @returns 最高级别的告警类型
|
||
*/
|
||
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
|
||
}
|
||
|
||
// ============================================================================
|
||
// 主组件
|
||
// ============================================================================
|
||
|
||
export const TokenUsageDashboard = ({ data }: TokenUsageDashboardProps) => {
|
||
const { opusModels } = data
|
||
const { theme, setTheme } = useTheme()
|
||
|
||
// UI 状态
|
||
const [alertMuted, setAlertMuted] = useState(false)
|
||
const [menuOpen, setMenuOpen] = useState(false)
|
||
|
||
// 计算告警信息 (使用 useMemo 缓存)
|
||
const alerts = useMemo(() => computeAlerts(opusModels), [opusModels])
|
||
const alertType = useMemo(() => getHighestAlertType(alerts), [alerts])
|
||
|
||
// 获取最重要的告警 (用于顶栏显示)
|
||
const topAlert = alerts[0]
|
||
|
||
// ========== 事件处理器 ==========
|
||
|
||
/** 切换告警静音状态 */
|
||
const handleMuteClick = useCallback(() => {
|
||
setAlertMuted((prev) => !prev)
|
||
}, [])
|
||
|
||
/** 切换菜单开关状态 */
|
||
const handleMenuButtonClick = useCallback(() => {
|
||
setMenuOpen((prev) => !prev)
|
||
}, [])
|
||
|
||
// ========== 渲染 ==========
|
||
|
||
return (
|
||
<div className="min-h-screen flex flex-col bg-[var(--container-background-color,#f7f7f7)] text-[var(--element-active-color,#3d3d3d)]">
|
||
{/* 顶部导航栏 */}
|
||
<TopBar
|
||
appTitle="Token Usage Viewer"
|
||
menuButtonActivated={menuOpen}
|
||
onMenuButtonClick={handleMenuButtonClick}
|
||
rightSlot={
|
||
<>
|
||
<AlertBadge
|
||
count={alerts.length}
|
||
alertType={alertType}
|
||
muted={alertMuted}
|
||
onMuteClick={handleMuteClick}
|
||
/>
|
||
{topAlert && (
|
||
<AlertNotification
|
||
account={extractUsername(topAlert.account)}
|
||
remainingPercent={Math.round(topAlert.remainingFraction * 100)}
|
||
alertType={topAlert.type}
|
||
/>
|
||
)}
|
||
</>
|
||
}
|
||
/>
|
||
|
||
{/* 主题切换侧边栏 */}
|
||
<ThemeSidebar
|
||
open={menuOpen}
|
||
onOpenChange={setMenuOpen}
|
||
currentTheme={theme}
|
||
onThemeChange={setTheme}
|
||
/>
|
||
|
||
{/* 主内容区 - 配额圆环展示 */}
|
||
<main className="flex-1 flex flex-col items-center justify-center p-8">
|
||
<div className="flex flex-wrap justify-center gap-10 lg:gap-16">
|
||
{opusModels.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>
|
||
)
|
||
}
|