feat: 替换 OpenBridge 组件并实现航海规范主题系统

- 使用 shadcn/ui 重新实现 TopBar、ThemeSidebar、AlertBadge 组件
- 解决 @oicl/openbridge-webcomponents ESM 模块解析问题
- 添加 OpenBridge 四种主题 CSS 变量 (day/bright/dusk/night)
- Night 主题使用暗黄色文字保护夜视能力
- 更新 API 端点适配新的按模型分组数据结构
This commit is contained in:
2026-01-26 21:17:56 +08:00
parent fa625ca301
commit d22a0f8d69
14 changed files with 1025 additions and 219 deletions

View File

@@ -2,64 +2,25 @@
* TokenUsageDashboard 组件
*
* 主仪表盘,展示多个账户的 claude-opus-4-5-thinking 配额使用情况。
* 使用 OpenBridge 设计系统的 TopBar 和 Alert 组件
* 使用自定义组件替代 OpenBridge 组件,基于 shadcn/ui 实现
*
* 特性:
* - 多账户配额可视化 (根据 API 返回的账户数量动态显示)
* - 实时告警通知 (低于 20% 警告,低于 5% 紧急)
* - 支持 OpenBridge 四种主题切换 (day/bright/dusk/night)
* - OpenBridge 组件懒加载以避免 SSR 问题
* - 支持四种主题切换 (day/bright/dusk/night)
*/
import '@oicl/openbridge-webcomponents/dist/icons/icon-palette-day.js'
import '@oicl/openbridge-webcomponents/dist/icons/icon-palette-day-bright.js'
import '@oicl/openbridge-webcomponents/dist/icons/icon-palette-dusk.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 { useCallback, useMemo, useState } from 'react'
import { HealthRing } from '@/components/HealthRing'
import { type ObcTheme, useTheme } from '@/hooks/useTheme'
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'
// ============================================================================
// 懒加载 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 })),
)
// ============================================================================
// 类型定义
// ============================================================================
@@ -171,15 +132,6 @@ const getHighestAlertType = (alerts: AlertInfo[]): AlertType => {
return AlertType.Caution
}
// ============================================================================
// 子组件
// ============================================================================
/** TopBar 加载占位符 (SSR 时显示) */
const TopBarFallback = () => (
<div className="h-14 bg-[var(--container-background-color)] border-b border-[var(--divider-color)]" />
)
// ============================================================================
// 主组件
// ============================================================================
@@ -211,149 +163,41 @@ export const TokenUsageDashboard = ({ data }: TokenUsageDashboardProps) => {
setMenuOpen((prev) => !prev)
}, [])
/** 关闭侧边菜单 */
const closeMenu = useCallback(() => {
setMenuOpen(false)
}, [])
/** 切换主题并关闭菜单 */
const handleThemeChange = useCallback(
(newTheme: ObcTheme) => {
setTheme(newTheme)
setMenuOpen(false)
},
[setTheme],
)
// ========== 渲染 ==========
return (
<div className="min-h-screen flex flex-col bg-[var(--container-background-color)] text-[var(--on-container-color)]">
<div className="min-h-screen flex flex-col bg-[var(--container-background-color,#f7f7f7)] text-[var(--element-active-color,#3d3d3d)]">
{/* 顶部导航栏 */}
<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">
<ObcAlertTopbarElement
nAlerts={alerts.length}
alertType={alertType}
alertMuted={alertMuted}
showAck={false}
onMuteclick={handleMuteClick as unknown as EventListener}
>
{topAlert && (
<ObcNotificationMessageItem time="">
<span slot="icon">
<ObcAlertIcon
name={
topAlert.type === AlertType.Alarm
? 'alarm-unack'
: 'warning-unack'
}
/>
</span>
<span slot="message">
{extractUsername(topAlert.account)}: {' '}
{Math.round(topAlert.remainingFraction * 100)}%
</span>
</ObcNotificationMessageItem>
)}
</ObcAlertTopbarElement>
</div>
</ObcTopBar>
</Suspense>
</header>
<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}
/>
)}
</>
}
/>
{/* 侧边导航菜单 */}
{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">
{/* 白天模式选项 */}
<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>
{/* 明亮模式选项 */}
<ObcNavigationItem
label="明亮模式"
checked={theme === 'bright'}
onClick={() => handleThemeChange('bright')}
>
<span
slot="icon"
// biome-ignore lint: custom element
dangerouslySetInnerHTML={{
__html:
'<obi-palette-day-bright></obi-palette-day-bright>',
}}
/>
</ObcNavigationItem>
{/* 黄昏模式选项 */}
<ObcNavigationItem
label="黄昏模式"
checked={theme === 'dusk'}
onClick={() => handleThemeChange('dusk')}
>
<span
slot="icon"
// biome-ignore lint: custom element
dangerouslySetInnerHTML={{
__html: '<obi-palette-dusk></obi-palette-dusk>',
}}
/>
</ObcNavigationItem>
{/* 夜间模式选项 */}
<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>
</div>
</ObcNavigationMenu>
</Suspense>
</aside>
)}
{/* 点击遮罩关闭菜单 */}
{menuOpen && (
<button
type="button"
aria-label="关闭菜单"
className="fixed inset-0 z-30 bg-black/20 cursor-default"
onClick={closeMenu}
onKeyDown={(e) => e.key === 'Escape' && closeMenu()}
/>
)}
{/* 主题切换侧边栏 */}
<ThemeSidebar
open={menuOpen}
onOpenChange={setMenuOpen}
currentTheme={theme}
onThemeChange={setTheme}
/>
{/* 主内容区 - 配额圆环展示 */}
<main className="flex-1 flex flex-col items-center justify-center p-8">