refactor: 优化代码结构,添加中文注释,完善 README 文档

- Hooks/组件添加 useMemo 优化,减少不必要的重计算
- 简化 TokenUsageDashboard 的 Suspense 嵌套层级
- 完善 README: 技术栈、构建产物位置、架构说明
This commit is contained in:
2026-01-21 20:15:34 +08:00
parent a77fcdd3dc
commit 13a873ec76
17 changed files with 944 additions and 365 deletions

View File

@@ -1,3 +1,27 @@
export function ErrorComponent() {
return <div>An unhandled error happened!</div>
/**
* 错误边界回退组件
*
* 当应用发生未捕获的错误时显示此组件。
* 提供友好的错误提示和刷新按钮。
*/
/** 错误页面组件 */
export const ErrorComponent = () => {
return (
<div className="min-h-screen flex items-center justify-center bg-[var(--container-background-color)] text-[var(--on-container-color)]">
<div className="text-center p-8">
<h1 className="text-2xl font-bold mb-4"></h1>
<p className="text-[var(--on-container-color)] opacity-70 mb-6">
</p>
<button
type="button"
onClick={() => window.location.reload()}
className="px-4 py-2 bg-[var(--primary-color)] text-white rounded hover:opacity-90 transition-opacity"
>
</button>
</div>
</div>
)
}

View File

@@ -1,11 +1,13 @@
/**
* HealthRing 组件
*
* Apple 健康风格的圆环进度指示器
* 中心显示倒计时
* Apple 健康风格的圆环进度指示器,用于可视化配额使用情况。
* 中心显示百分比和倒计时,颜色根据剩余配额自动变化。
*/
import { useMemo } from 'react'
import { useCountdown } from '@/hooks/useCountdown'
/** 组件 Props 类型定义 */
export interface HealthRingProps {
/** 账户名称 */
account: string
@@ -17,28 +19,36 @@ export interface HealthRingProps {
remainingFraction: number
/** 配额重置时间 (ISO 8601) */
resetTime?: string
/** 圆环尺寸 */
/** 圆环尺寸 (像素),默认 160 */
size?: number
}
/**
* 根据剩余配额获取颜色
* 颜色阈值配置
* 根据剩余配额百分比决定显示颜色
*/
const getRingColor = (fraction: number): string => {
if (fraction < 0.05) return '#FF3B30' // 红色 - 紧急
if (fraction < 0.2) return '#FF9500' // 橙色 - 警告
if (fraction < 0.5) return '#FFCC00' // 黄色 - 注意
return '#34C759' // 绿色 - 正常
}
const COLOR_THRESHOLDS = [
{ threshold: 0.05, color: '#FF3B30', bgColor: 'rgba(255, 59, 48, 0.2)' }, // 红色 - 紧急
{ threshold: 0.2, color: '#FF9500', bgColor: 'rgba(255, 149, 0, 0.2)' }, // 橙色 - 警告
{ threshold: 0.5, color: '#FFCC00', bgColor: 'rgba(255, 204, 0, 0.2)' }, // 黄色 - 注意
] as const
/** 默认颜色 (绿色 - 正常状态) */
const DEFAULT_COLOR = { color: '#34C759', bgColor: 'rgba(52, 199, 89, 0.2)' }
/**
* 根据剩余配额获取背景色(较暗)
* 根据剩余配额获取对应的颜色配置
*
* @param fraction - 剩余配额百分比 (0-1)
* @returns 前景色和背景色配置
*/
const getRingBgColor = (fraction: number): string => {
if (fraction < 0.05) return 'rgba(255, 59, 48, 0.2)'
if (fraction < 0.2) return 'rgba(255, 149, 0, 0.2)'
if (fraction < 0.5) return 'rgba(255, 204, 0, 0.2)'
return 'rgba(52, 199, 89, 0.2)'
const getColorConfig = (
fraction: number,
): { color: string; bgColor: string } => {
for (const { threshold, color, bgColor } of COLOR_THRESHOLDS) {
if (fraction < threshold) return { color, bgColor }
}
return DEFAULT_COLOR
}
export const HealthRing = ({
@@ -50,20 +60,25 @@ export const HealthRing = ({
size = 160,
}: HealthRingProps) => {
const countdown = useCountdown(resetTime)
// 使用 useMemo 缓存 SVG 计算值,避免不必要的重新计算
const svgParams = useMemo(() => {
const strokeWidth = size * 0.1
const radius = (size - strokeWidth) / 2
const circumference = 2 * Math.PI * radius
const strokeDashoffset = circumference * (1 - remainingFraction)
return { strokeWidth, radius, circumference, strokeDashoffset }
}, [size, remainingFraction])
const percentage = Math.round(remainingFraction * 100)
// SVG 参数
const strokeWidth = size * 0.1
const radius = (size - strokeWidth) / 2
const circumference = 2 * Math.PI * radius
const strokeDashoffset = circumference * (1 - remainingFraction)
const ringColor = getRingColor(remainingFraction)
const ringBgColor = getRingBgColor(remainingFraction)
const { color: ringColor, bgColor: ringBgColor } =
getColorConfig(remainingFraction)
const { strokeWidth, radius, circumference, strokeDashoffset } = svgParams
return (
<div className="flex flex-col items-center gap-3">
{/* 圆环 */}
{/* 圆环容器 */}
<div className="relative" style={{ width: size, height: size }}>
<svg
width={size}
@@ -97,7 +112,7 @@ export const HealthRing = ({
/>
</svg>
{/* 中心内容 - 倒计时 */}
{/* 中心内容 - 百分比和倒计时 */}
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-2xl font-bold" style={{ color: ringColor }}>
{percentage}%
@@ -108,7 +123,7 @@ export const HealthRing = ({
</div>
</div>
{/* 标签 */}
{/* 底部标签 */}
<div className="text-center">
<div
className="text-sm font-medium truncate max-w-[140px] text-[var(--on-container-color)]"

View File

@@ -1,3 +1,29 @@
export function NotFoundComponent() {
return <div>404 - Not Found</div>
/**
* 404 页面未找到组件
*
* 当用户访问不存在的路由时显示此组件。
* 提供友好的提示和返回首页按钮。
*/
/** 404 页面组件 */
export const NotFoundComponent = () => {
return (
<div className="min-h-screen flex items-center justify-center bg-[var(--container-background-color)] text-[var(--on-container-color)]">
<div className="text-center p-8">
<h1 className="text-6xl font-bold mb-4 text-[var(--on-container-color)] opacity-30">
404
</h1>
<h2 className="text-xl font-medium mb-4"></h2>
<p className="text-[var(--on-container-color)] opacity-70 mb-6">
访
</p>
<a
href="/"
className="inline-block px-4 py-2 bg-[var(--primary-color)] text-white rounded hover:opacity-90 transition-opacity"
>
</a>
</div>
</div>
)
}

View File

@@ -1,18 +1,27 @@
/**
* TokenUsageDashboard 组件
*
* 主仪表盘,展示 4 个账户的 claude-opus-4-5-thinking 配额使用情况
* 使用 OpenBridge TopBar + AlertTopbarElement
* 主仪表盘,展示个账户的 claude-opus-4-5-thinking 配额使用情况
* 使用 OpenBridge 设计系统的 TopBar Alert 组件。
*
* 特性:
* - 多账户配额可视化 (最多显示 4 个)
* - 实时告警通知 (低于 20% 警告,低于 5% 紧急)
* - 支持日间/夜间主题切换
* - OpenBridge 组件懒加载以避免 SSR 问题
*/
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 ObcTheme, useTheme } from '@/hooks/useTheme'
import type { ModelUsage } from '@/orpc/contracts/usage'
// 懒加载 OpenBridge 组件以避免 SSR 问题
// ============================================================================
// 懒加载 OpenBridge 组件(避免 SSR 问题)
// ============================================================================
const ObcTopBar = lazy(() =>
import(
'@oicl/openbridge-webcomponents-react/components/top-bar/top-bar'
@@ -49,17 +58,19 @@ const ObcNavigationItem = lazy(() =>
).then((mod) => ({ default: mod.ObcNavigationItem })),
)
// ============================================================================
// 类型定义
// ============================================================================
export interface TokenUsageDashboardProps {
/** 从 API 获取的使用量数据 */
data: {
opusModels: ModelUsage[]
fetchedAt: string
}
}
/** 告警阈值 */
const ALERT_THRESHOLD = 0.2 // 20% 警戒线
const CRITICAL_THRESHOLD = 0.05 // 5% 紧急阈值
/** 告警信息类型 */
interface AlertInfo {
account: string
model: string
@@ -68,18 +79,37 @@ interface AlertInfo {
type: AlertType
}
// ============================================================================
// 常量配置
// ============================================================================
/** 告警阈值配置 */
const ALERT_THRESHOLD = 0.2 // 20% - 警告阈值
const CRITICAL_THRESHOLD = 0.05 // 5% - 紧急阈值
/** 最大显示的账户数 */
const MAX_DISPLAY_ACCOUNTS = 4
/** 已知的账户前缀列表 */
const KNOWN_PREFIXES = [
'antigravity-',
'anthropic-',
'claude-',
'openai-',
] as const
// ============================================================================
// 工具函数
// ============================================================================
/**
* 从账户名中提取用户名部分
* 例如: "antigravity-2220328339_qq" -> "2220328339_qq"
*
* @param account - 完整账户名 (如 "antigravity-2220328339_qq")
* @returns 提取后的用户名 (如 "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) {
for (const prefix of KNOWN_PREFIXES) {
if (account.startsWith(prefix)) {
return account.slice(prefix.length)
}
@@ -89,11 +119,17 @@ const extractUsername = (account: string): string => {
/**
* 计算告警列表
*
* 根据配额剩余比例生成告警列表,并按严重程度排序。
*
* @param models - 模型使用量列表
* @returns 排序后的告警列表 (Alarm 优先)
*/
const getAlerts = (models: ModelUsage[]): AlertInfo[] => {
const computeAlerts = (models: ModelUsage[]): AlertInfo[] => {
const alerts: AlertInfo[] = []
for (const model of models) {
// 低于 5% 为紧急告警
if (model.remainingFraction < CRITICAL_THRESHOLD) {
alerts.push({
account: model.account,
@@ -102,7 +138,9 @@ const getAlerts = (models: ModelUsage[]): AlertInfo[] => {
remainingFraction: model.remainingFraction,
type: AlertType.Alarm,
})
} else if (model.remainingFraction < ALERT_THRESHOLD) {
}
// 低于 20% 为警告
else if (model.remainingFraction < ALERT_THRESHOLD) {
alerts.push({
account: model.account,
model: model.model,
@@ -113,16 +151,20 @@ const getAlerts = (models: ModelUsage[]): AlertInfo[] => {
}
}
// 按严重程度排序Alarm 优先)
// 按严重程度排序: Alarm > Warning相同级别按剩余配额升序
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
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
@@ -130,42 +172,62 @@ const getHighestAlertType = (alerts: AlertInfo[]): AlertType => {
return AlertType.Caution
}
/**
* TopBar 占位符SSR 时显示)
*/
// ============================================================================
// 子组件
// ============================================================================
/** 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()
// UI 状态
const [alertMuted, setAlertMuted] = useState(false)
const [menuOpen, setMenuOpen] = useState(false)
// 计算告警
const alerts = useMemo(() => getAlerts(opusModels), [opusModels])
const alertType = getHighestAlertType(alerts)
// 计算告警信息 (使用 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)
}, [])
// 处理主题切换
/** 关闭侧边菜单 */
const closeMenu = useCallback(() => {
setMenuOpen(false)
}, [])
/** 切换主题并关闭菜单 */
const handleThemeChange = useCallback(
(newTheme: 'day' | 'night') => {
(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)]">
{/* 顶部导航栏 */}
@@ -179,39 +241,33 @@ export const TokenUsageDashboard = ({ data }: TokenUsageDashboardProps) => {
}
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>
<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>
@@ -223,36 +279,35 @@ export const TokenUsageDashboard = ({ data }: TokenUsageDashboardProps) => {
<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>
{/* 白天模式选项 */}
<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 === 'night'}
onClick={() => handleThemeChange('night')}
>
<span
slot="icon"
// biome-ignore lint: custom element
dangerouslySetInnerHTML={{
__html: '<obi-palette-night></obi-palette-night>',
}}
/>
</ObcNavigationItem>
</div>
</ObcNavigationMenu>
</Suspense>
@@ -265,16 +320,15 @@ export const TokenUsageDashboard = ({ data }: TokenUsageDashboardProps) => {
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)}
onClick={closeMenu}
onKeyDown={(e) => e.key === 'Escape' && closeMenu()}
/>
)}
{/* 主内容区 */}
{/* 主内容区 - 配额圆环展示 */}
<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) => (
{opusModels.slice(0, MAX_DISPLAY_ACCOUNTS).map((model) => (
<HealthRing
key={`${model.account}-${model.model}`}
account={extractUsername(model.account)}

View File

@@ -1,8 +1,13 @@
/**
* 数据库连接模块
*
* 使用 Bun 内置的 SQLite 驱动,无需额外安装原生模块。
* 数据库文件存储在可执行文件同的 data/app.db
* 使用 Bun 内置的 SQLite 驱动 (bun:sqlite),无需安装额外的原生模块。
* 数据库文件存储在可执行文件同目录的 data/app.db
*
* 特性:
* - WAL 模式提升并发性能
* - 自动创建表结构
* - 智能路径检测 (开发/生产)
*/
import { Database } from 'bun:sqlite'
@@ -11,16 +16,31 @@ import { dirname, join } from 'node:path'
import { drizzle } from 'drizzle-orm/bun-sqlite'
import * as schema from '@/db/schema'
/**
* 获取数据库路径
* - 在打包后的 sidecar 中,使用可执行文件所在目录
* - 在开发模式下,使用项目根目录
*/
function getDbPath(): string {
const execPath = process.execPath
const isBundled = !execPath.includes('node') && !execPath.includes('bun')
// ============================================================================
// 路径工具函数
// ============================================================================
const baseDir = isBundled ? dirname(execPath) : process.cwd()
/**
* 判断是否为打包后的可执行文件运行环境
*
* @returns 是否为打包后的二进制文件
*/
const isBundledExec = (): boolean => {
const execPath = process.execPath
return !execPath.includes('node') && !execPath.includes('bun')
}
/**
* 获取数据库文件存储路径
*
* 路径策略:
* - 打包后的 sidecar: 使用可执行文件所在目录
* - 开发模式: 使用项目根目录 (process.cwd())
*
* @returns 数据库文件完整路径
*/
const getDbPath = (): string => {
const baseDir = isBundledExec() ? dirname(process.execPath) : process.cwd()
const dataDir = join(baseDir, 'data')
// 确保 data 目录存在
@@ -31,14 +51,24 @@ function getDbPath(): string {
return join(dataDir, 'app.db')
}
/** 数据库文件路径 */
// ============================================================================
// 表初始化
// ============================================================================
/** 数据库文件路径 (在模块加载时计算一次) */
const DB_PATH = getDbPath()
/**
* 初始化数据库表结构
*
* 使用 IF NOT EXISTS 确保幂等性,可安全多次执行。
* 创建 usage_history 表和相应的索引。
*
* @param sqlite - SQLite 数据库实例
*/
function initTables(sqlite: Database) {
const initTables = (sqlite: Database): void => {
sqlite.exec(`
-- 使用量历史记录表
CREATE TABLE IF NOT EXISTS usage_history (
id TEXT PRIMARY KEY,
account TEXT NOT NULL,
@@ -48,26 +78,38 @@ function initTables(sqlite: Database) {
reset_time TEXT,
recorded_at INTEGER NOT NULL DEFAULT (unixepoch())
);
-- 按记录时间查询的索引
CREATE INDEX IF NOT EXISTS idx_usage_history_recorded_at
ON usage_history(recorded_at);
`)
}
// ============================================================================
// 数据库连接
// ============================================================================
/**
* 创建数据库连接
*
* 启用 WAL (Write-Ahead Logging) 模式以提高并发读写性能。
* 如果数据库文件不存在,会自动创建并初始化表结构。
* 特性:
* - 启用 WAL (Write-Ahead Logging) 模式提高并发读写性能
* - 自动创建数据库文件 (如不存在)
* - 自动初始化表结构
*
* @returns Drizzle ORM 数据库实例
*/
export function createDb() {
export const createDb = () => {
const sqlite = new Database(DB_PATH, { create: true })
// 启用 WAL 模式,提升并发性能
sqlite.exec('PRAGMA journal_mode = WAL;')
// 自动初始化表结构
// 初始化表结构
initTables(sqlite)
return drizzle(sqlite, { schema })
}
/** 数据库实例类型 */
/** 数据库实例类型 (用于 TypeScript 类型推导) */
export type Db = ReturnType<typeof createDb>

View File

@@ -1,11 +1,72 @@
/**
* 环境变量配置模块
*
* 使用 @t3-oss/env-core 进行类型安全的环境变量验证。
* 支持从同目录的 .env 文件加载配置(优先级低于系统环境变量)。
*/
import { existsSync, readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { createEnv } from '@t3-oss/env-core'
import { z } from 'zod'
/** 默认的 TOKEN_USAGE_URL */
/** Token 使用量 API 的默认地址 */
const DEFAULT_TOKEN_USAGE_URL = 'http://10.0.1.1:8318/usage'
/**
* 判断当前是否为打包后的可执行文件运行环境
*
* @returns 是否为打包后的二进制文件
*/
const isBundledExec = (): boolean => {
const execPath = process.execPath
return !execPath.includes('node') && !execPath.includes('bun')
}
/**
* 获取配置文件的基础目录
*
* - 打包后的 sidecar: 使用可执行文件所在目录
* - 开发模式: 使用项目根目录
*
* @returns 基础目录路径
*/
const getBaseDir = (): string =>
isBundledExec() ? dirname(process.execPath) : process.cwd()
/**
* 解析 .env 文件内容
*
* 支持:
* - 空行和 # 开头的注释
* - KEY=value 格式
* - 只设置系统环境变量中不存在的变量
*
* @param content - .env 文件内容
* @returns 解析后的环境变量对象
*/
const parseEnvContent = (content: string): Record<string, string> => {
const result: Record<string, string> = {}
for (const line of content.split('\n')) {
const trimmed = line.trim()
// 跳过空行和注释
if (!trimmed || trimmed.startsWith('#')) continue
const eqIndex = trimmed.indexOf('=')
if (eqIndex <= 0) continue
const key = trimmed.slice(0, eqIndex).trim()
const value = trimmed.slice(eqIndex + 1).trim()
// 只设置系统环境变量中不存在的变量
if (!process.env[key]) {
result[key] = value
}
}
return result
}
/**
* 从同目录的 .env 配置文件读取环境变量
*
@@ -13,63 +74,54 @@ const DEFAULT_TOKEN_USAGE_URL = 'http://10.0.1.1:8318/usage'
* 1. 系统环境变量 (process.env)
* 2. 可执行文件同目录的 .env 文件
* 3. 默认值
*
* @returns 从文件解析的环境变量
*/
function loadEnvFromFile(): Record<string, string> {
const result: Record<string, string> = {}
const loadEnvFromFile = (): Record<string, string> => {
const envPath = join(getBaseDir(), '.env')
// 确定可执行文件所在目录
const execPath = process.execPath
const isBundled = !execPath.includes('node') && !execPath.includes('bun')
const baseDir = isBundled ? dirname(execPath) : process.cwd()
const envPath = join(baseDir, '.env')
if (!existsSync(envPath)) return {}
// 如果 .env 文件存在,解析它
if (existsSync(envPath)) {
try {
const content = readFileSync(envPath, 'utf-8')
for (const line of content.split('\n')) {
const trimmed = line.trim()
// 跳过空行和注释
if (!trimmed || trimmed.startsWith('#')) continue
try {
const content = readFileSync(envPath, 'utf-8')
return parseEnvContent(content)
} catch {
// 忽略读取错误(权限问题等)
return {}
}
}
const eqIndex = trimmed.indexOf('=')
if (eqIndex > 0) {
const key = trimmed.slice(0, eqIndex).trim()
const value = trimmed.slice(eqIndex + 1).trim()
// 只设置不存在的环境变量
if (!process.env[key]) {
result[key] = value
}
}
}
} catch {
// 忽略读取错误
/**
* 构建合并后的环境变量对象
*
* 合并顺序: process.env > fileEnv > 默认值
*/
const buildMergedEnv = (): Record<string, string | undefined> => {
const fileEnv = loadEnvFromFile()
const merged: Record<string, string | undefined> = { ...process.env }
// 从文件填充缺失的变量
for (const [key, value] of Object.entries(fileEnv)) {
if (!merged[key]) {
merged[key] = value
}
}
return result
}
// 加载配置文件中的环境变量
const fileEnv = loadEnvFromFile()
// 合并环境变量: process.env > fileEnv > 默认值
const mergedEnv: Record<string, string | undefined> = {
...process.env,
}
// 从文件填充缺失的变量
for (const [key, value] of Object.entries(fileEnv)) {
if (!mergedEnv[key]) {
mergedEnv[key] = value
}
}
// 如果仍然没有 TOKEN_USAGE_URL使用默认值
if (!mergedEnv.TOKEN_USAGE_URL) {
mergedEnv.TOKEN_USAGE_URL = DEFAULT_TOKEN_USAGE_URL
// 设置默认值
merged.TOKEN_USAGE_URL ??= DEFAULT_TOKEN_USAGE_URL
return merged
}
/**
* 类型安全的环境变量配置
*
* 服务端变量:
* - TOKEN_USAGE_URL: Token 使用量 API 地址
*
* 客户端变量 (VITE_ 前缀):
* - VITE_APP_TITLE: 应用标题 (可选)
*/
export const env = createEnv({
server: {
TOKEN_USAGE_URL: z.string().url(),
@@ -78,6 +130,6 @@ export const env = createEnv({
client: {
VITE_APP_TITLE: z.string().min(1).optional(),
},
runtimeEnv: mergedEnv,
runtimeEnv: buildMergedEnv(),
emptyStringAsUndefined: true,
})

View File

@@ -1,10 +1,12 @@
/**
* 倒计时 Hook
*
* 计算目标时间到当前时间的剩余时间,每秒更新一次
* 计算目标时间到当前时间的剩余时间,每秒更新一次
* 使用 useMemo 优化计算,避免不必要的重渲染。
*/
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
/** 倒计时结果类型 */
export interface CountdownResult {
/** 剩余总秒数 */
totalSeconds: number
@@ -20,58 +22,84 @@ export interface CountdownResult {
isExpired: boolean
}
/** 时间常量 (毫秒) */
const SECOND = 1000
const MINUTE = 60
const HOUR = 3600
/** 无效/过期时的默认返回值 */
const EXPIRED_RESULT: CountdownResult = {
totalSeconds: 0,
hours: 0,
minutes: 0,
seconds: 0,
formatted: '--:--',
isExpired: true,
}
/**
* 格式化倒计时为可读字符串
*
* @param hours - 小时数
* @param minutes - 分钟数
* @param seconds - 秒数
* @returns 格式化后的字符串 (如 "2h 15m" 或 "45m 30s")
*/
const formatCountdown = (
hours: number,
minutes: number,
seconds: number,
): string => {
if (hours > 0) return `${hours}h ${minutes}m`
if (minutes > 0) return `${minutes}m ${seconds}s`
return `${seconds}s`
}
/**
* 计算并实时更新目标时间的倒计时
* @param targetTime ISO 8601 格式的目标时间字符串
* @returns 倒计时结果
*
* @param targetTime - ISO 8601 格式的目标时间字符串
* @returns 倒计时结果对象
*
* @example
* ```tsx
* const countdown = useCountdown('2024-12-31T23:59:59Z')
* console.log(countdown.formatted) // "2h 15m"
* ```
*/
export const useCountdown = (
targetTime: string | undefined,
): CountdownResult => {
const [now, setNow] = useState(() => Date.now())
const [now, setNow] = useState(Date.now)
// 每秒更新当前时间
useEffect(() => {
const timer = setInterval(() => {
setNow(Date.now())
}, 1000)
const timer = setInterval(() => setNow(Date.now()), SECOND)
return () => clearInterval(timer)
}, [])
if (!targetTime) {
// 使用 useMemo 缓存计算结果,仅在 now 或 targetTime 变化时重新计算
return useMemo(() => {
if (!targetTime) return EXPIRED_RESULT
const target = new Date(targetTime).getTime()
const diff = Math.max(0, target - now)
const totalSeconds = Math.floor(diff / SECOND)
// 已过期
if (totalSeconds === 0) return EXPIRED_RESULT
const hours = Math.floor(totalSeconds / HOUR)
const minutes = Math.floor((totalSeconds % HOUR) / MINUTE)
const seconds = totalSeconds % MINUTE
return {
totalSeconds: 0,
hours: 0,
minutes: 0,
seconds: 0,
formatted: '--:--',
isExpired: true,
totalSeconds,
hours,
minutes,
seconds,
formatted: formatCountdown(hours, minutes, seconds),
isExpired: false,
}
}
const target = new Date(targetTime).getTime()
const diff = Math.max(0, target - now)
const totalSeconds = Math.floor(diff / 1000)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
let formatted: string
if (hours > 0) {
formatted = `${hours}h ${minutes}m`
} else if (minutes > 0) {
formatted = `${minutes}m ${seconds}s`
} else {
formatted = `${seconds}s`
}
return {
totalSeconds,
hours,
minutes,
seconds,
formatted,
isExpired: totalSeconds === 0,
}
}, [now, targetTime])
}

View File

@@ -1,42 +1,70 @@
/**
* OpenBridge 主题管理 Hook
*
* 管理 data-obc-theme 属性,支持 day/dusk/night/bright 四种主题
* 主题选择会持久化到 localStorage
* 管理 data-obc-theme 属性,支持 day/dusk/night/bright 四种主题
* 主题选择会持久化到 localStorage
*/
import { useCallback, useEffect, useState } from 'react'
/** 支持的 OpenBridge 主题类型 */
export type ObcTheme = 'day' | 'dusk' | 'night' | 'bright'
/** 主题列表,用于循环切换 */
const THEMES: readonly ObcTheme[] = ['day', 'dusk', 'night', 'bright'] as const
/** localStorage 存储键名 */
const STORAGE_KEY = 'obc-theme'
/** 默认主题 */
const DEFAULT_THEME: ObcTheme = 'day'
/**
* 类型守卫:检查值是否为有效的主题
*
* @param value - 待检查的值
* @returns 是否为有效主题
*/
const isValidTheme = (value: unknown): value is ObcTheme =>
typeof value === 'string' && THEMES.includes(value as ObcTheme)
/**
* 获取初始主题(从 localStorage 或默认值)
*
* @returns 初始主题值
*/
const getInitialTheme = (): ObcTheme => {
if (typeof window === 'undefined') {
return DEFAULT_THEME
}
// SSR 环境下使用默认主题
if (typeof window === 'undefined') return DEFAULT_THEME
const stored = localStorage.getItem(STORAGE_KEY)
if (stored && ['day', 'dusk', 'night', 'bright'].includes(stored)) {
return stored as ObcTheme
}
return DEFAULT_THEME
return isValidTheme(stored) ? stored : DEFAULT_THEME
}
/**
* 管理 OpenBridge 主题切换
*
* @returns 主题状态和控制函数
*
* @example
* ```tsx
* const { theme, setTheme, cycleTheme } = useTheme()
*
* // 设置为夜间模式
* setTheme('night')
*
* // 循环切换到下一个主题
* cycleTheme()
* ```
*/
export const useTheme = () => {
const [theme, setThemeState] = useState<ObcTheme>(getInitialTheme)
// 应用主题到 DOM
// 应用主题到 DOM 并持久化
useEffect(() => {
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('data-obc-theme', theme)
localStorage.setItem(STORAGE_KEY, theme)
}
if (typeof document === 'undefined') return
document.documentElement.setAttribute('data-obc-theme', theme)
localStorage.setItem(STORAGE_KEY, theme)
}, [theme])
// 切换到指定主题
@@ -46,18 +74,12 @@ export const useTheme = () => {
// 循环切换主题
const cycleTheme = useCallback(() => {
const themes: ObcTheme[] = ['day', 'dusk', 'night', 'bright']
const currentIndex = themes.indexOf(theme)
const nextIndex = (currentIndex + 1) % themes.length
const nextTheme = themes[nextIndex]
if (nextTheme) {
setThemeState(nextTheme)
}
}, [theme])
setThemeState((current) => {
const currentIndex = THEMES.indexOf(current)
const nextIndex = (currentIndex + 1) % THEMES.length
return THEMES[nextIndex] ?? DEFAULT_THEME
})
}, [])
return {
theme,
setTheme,
cycleTheme,
}
return { theme, setTheme, cycleTheme } as const
}

View File

@@ -1,11 +1,11 @@
/**
* ORPC 同构客户端
*
* 根据运行环境自动选择最优调用方式:
* - SSR (服务端): 直接调用 router,无 HTTP 开销
* - CSR (客户端): 通过 /api/rpc 端点 HTTP 调用
* 根据运行环境自动选择最优的 RPC 调用方式:
* - SSR (服务端): 直接调用 router 处理器,零网络开销
* - CSR (客户端): 通过 /api/rpc 端点进行 HTTP 调用
*
* 同时配置了 TanStack Query 集成mutation 成功后自动刷新相关查询
* 同时集成 TanStack Query,提供开箱即用的查询/突变 hooks
*/
import { createORPCClient } from '@orpc/client'
import { RPCLink } from '@orpc/client/fetch'
@@ -16,36 +16,58 @@ import { getRequestHeaders } from '@tanstack/react-start/server'
import { router } from './router'
import type { RouterClient } from './types'
// ============================================================================
// 客户端创建
// ============================================================================
/**
* 创建同构 ORPC 客户端
*
* 服务端: 直接调用路由处理器
* 客户端: 通过 HTTP 调用 /api/rpc 端点
* 使用 TanStack Start 的 createIsomorphicFn 实现服务端/客户端代码分离:
* - server(): 在 SSR 时执行,直接调用路由处理器
* - client(): 在浏览器中执行,通过 HTTP 调用 API
*/
const getORPCClient = createIsomorphicFn()
.server(() =>
// 服务端: 创建直接调用路由器的客户端
createRouterClient(router, {
// 传递原始请求的 headers (用于身份验证等)
context: () => ({
headers: getRequestHeaders(),
}),
}),
)
.client(() => {
// 客户端: 创建 HTTP 客户端
const link = new RPCLink({
url: `${window.location.origin}/api/rpc`,
})
return createORPCClient<RouterClient>(link)
})
/** 同构客户端实例 */
const client: RouterClient = getORPCClient()
// ============================================================================
// TanStack Query 集成
// ============================================================================
/**
* ORPC + TanStack Query 工具
* ORPC + TanStack Query 工具
*
* 使用方式:
* 提供类型安全的 queryOptions 和 mutationOptions 方法。
*
* @example
* ```tsx
* // 查询
* // 查询 (使用 Suspense)
* const { data } = useSuspenseQuery(orpc.usage.getUsage.queryOptions())
*
* // 查询 (不使用 Suspense)
* const { data, isLoading } = useQuery(orpc.usage.getUsage.queryOptions())
*
* // 突变
* const mutation = useMutation(orpc.todo.create.mutationOptions())
* mutation.mutate({ title: '新任务' })
* ```
*/
export const orpc = createTanstackQueryUtils(client)

View File

@@ -1,26 +1,44 @@
/**
* 路由配置模块
*
* 创建并配置 TanStack Router 实例,集成 TanStack Query 进行 SSR 数据获取。
*
* 特性:
* - 自动滚动恢复
* - SSR 查询集成
* - 文件路由(由 routeTree.gen.ts 生成)
*/
import { QueryClient } from '@tanstack/react-query'
import { createRouter } from '@tanstack/react-router'
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
import type { RouterContext } from './routes/__root'
import { routeTree } from './routeTree.gen'
/**
* 创建路由实例工厂函数
*
* 每次调用创建新的 QueryClient 和 Router 实例。
* 这对于 SSR 很重要,避免请求之间共享状态。
*
* @returns 配置好的 TanStack Router 实例
*/
export const getRouter = () => {
// 创建 TanStack Query 客户端
const queryClient = new QueryClient()
// 创建路由实例
const router = createRouter({
routeTree,
context: {
queryClient,
} satisfies RouterContext,
context: { queryClient } satisfies RouterContext,
// 启用滚动位置恢复
scrollRestoration: true,
// 预加载数据立即过期,确保总是获取最新数据
defaultPreloadStaleTime: 0,
})
setupRouterSsrQueryIntegration({
router,
queryClient,
})
// 设置 SSR 查询集成
// 将路由器的预取与 TanStack Query 的缓存连接起来
setupRouterSsrQueryIntegration({ router, queryClient })
return router
}

View File

@@ -1,3 +1,13 @@
/**
* 根布局组件
*
* 定义应用的 HTML 结构、全局样式和错误处理。
*
* 特性:
* - OpenBridge 设计系统集成 (CSS 变量主题)
* - TanStack Router 上下文配置
* - 全局错误边界和 404 处理
*/
import '@oicl/openbridge-webcomponents/src/palettes/variables.css'
import type { QueryClient } from '@tanstack/react-query'
import {
@@ -10,36 +20,44 @@ import { ErrorComponent } from '@/components/Error'
import { NotFoundComponent } from '@/components/NotFount'
import appCss from '@/styles.css?url'
/** 路由上下文类型 - 包含 TanStack Query 客户端 */
export interface RouterContext {
queryClient: QueryClient
}
/**
* 根路由定义
*
* 配置:
* - head: 页面 meta 标签和样式表
* - shellComponent: HTML 文档结构
* - errorComponent: 错误边界回退
* - notFoundComponent: 404 页面
*/
export const Route = createRootRouteWithContext<RouterContext>()({
head: () => ({
meta: [
{
charSet: 'utf-8',
},
{
name: 'viewport',
content: 'width=device-width, initial-scale=1',
},
{
title: 'Token Usage Viewer',
},
],
links: [
{
rel: 'stylesheet',
href: appCss,
},
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ title: 'Token Usage Viewer' },
],
links: [{ rel: 'stylesheet', href: appCss }],
}),
shellComponent: RootDocument,
errorComponent: () => <ErrorComponent />,
notFoundComponent: () => <NotFoundComponent />,
})
/**
* HTML 文档根结构
*
* 设置:
* - lang="zh-Hans": 简体中文
* - data-obc-theme="day": OpenBridge 默认日间主题
* - obc-component-size-regular: OpenBridge 标准组件尺寸
*
* @param children - 页面内容
*/
function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
return (
<html lang="zh-Hans" data-obc-theme="day">

View File

@@ -1,7 +1,12 @@
/**
* Token Usage Viewer 主页面
*
* 展示 Opus/Thinking 模型的配额使用情况仪表盘
* 展示 claude-opus-4-5-thinking 模型的配额使用情况仪表盘
*
* 特性:
* - SSR 预加载数据(通过 loader
* - 每 5 分钟自动刷新配额数据
* - Tauri 环境下自动设置窗口标题
*/
import { useSuspenseQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
@@ -11,8 +16,16 @@ import { useEffect } from 'react'
import { TokenUsageDashboard } from '@/components/TokenUsageDashboard'
import { orpc } from '@/orpc'
/** 数据自动刷新间隔 (毫秒) - 5 分钟 */
const REFETCH_INTERVAL = 5 * 60 * 1000
export const Route = createFileRoute('/')({
component: Home,
/**
* 路由加载器 - SSR 数据预取
*
* 在服务端渲染时预先获取使用量数据,确保首次渲染即有内容。
*/
loader: async ({ context }) => {
await context.queryClient.ensureQueryData(
orpc.usage.getUsage.queryOptions(),
@@ -20,13 +33,19 @@ export const Route = createFileRoute('/')({
},
})
/**
* 首页组件
*
* 使用 useSuspenseQuery 获取并展示配额数据,
* 确保 data 不为空(由 loader 预取保证)。
*/
function Home() {
const { data } = useSuspenseQuery({
...orpc.usage.getUsage.queryOptions(),
refetchInterval: 300000, // 每 5 分钟自动刷新
refetchInterval: REFETCH_INTERVAL,
})
// 设置 Tauri 窗口标题
// Tauri 环境: 设置窗口标题
useEffect(() => {
if (!isTauri()) return
getCurrentWindow().setTitle('Token Usage Viewer')