🏁 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,128 @@
/**
* HealthRing 组件
*
* Apple 健康风格的圆环进度指示器
* 中心显示倒计时
*/
import { useCountdown } from '@/hooks/useCountdown'
export interface HealthRingProps {
/** 账户名称 */
account: string
/** 模型名称(可选) */
model?: string
/** 模型显示名称 */
displayName?: string
/** 剩余配额百分比 (0-1) */
remainingFraction: number
/** 配额重置时间 (ISO 8601) */
resetTime?: string
/** 圆环尺寸 */
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 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)'
}
export const HealthRing = ({
account,
model,
displayName,
remainingFraction,
resetTime,
size = 160,
}: HealthRingProps) => {
const countdown = useCountdown(resetTime)
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)
return (
<div className="flex flex-col items-center gap-3">
{/* 圆环 */}
<div className="relative" style={{ width: size, height: size }}>
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
className="transform -rotate-90"
role="img"
aria-label={`配额剩余 ${percentage}%`}
>
{/* 背景圆环 */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={ringBgColor}
strokeWidth={strokeWidth}
/>
{/* 进度圆环 */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={ringColor}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
className="transition-all duration-500 ease-out"
/>
</svg>
{/* 中心内容 - 倒计时 */}
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-2xl font-bold" style={{ color: ringColor }}>
{percentage}%
</span>
<span className="text-sm text-[var(--on-container-color)] opacity-70">
{countdown.formatted}
</span>
</div>
</div>
{/* 标签 */}
<div className="text-center">
<div
className="text-sm font-medium truncate max-w-[140px] text-[var(--on-container-color)]"
title={displayName || model}
>
{displayName || 'Claude Opus 4.5'}
</div>
<div
className="text-xs truncate max-w-[140px] text-[var(--on-container-color)] opacity-50"
title={account}
>
{account}
</div>
</div>
</div>
)
}

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>
)
}

View File

@@ -39,13 +39,17 @@ const DB_PATH = getDbPath()
*/
function initTables(sqlite: Database) {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS todo (
CREATE TABLE IF NOT EXISTS usage_history (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
completed INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
account TEXT NOT NULL,
model TEXT NOT NULL,
display_name TEXT,
remaining_fraction REAL NOT NULL,
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);
`)
}

View File

@@ -1 +1 @@
export * from './todo'
export * from './usage-history'

View File

@@ -1,33 +0,0 @@
/**
* Todo 表 Schema
*
* 使用 SQLite 数据类型:
* - text: 字符串类型
* - integer: 整数类型 (可配置为 boolean/timestamp 模式)
*/
import { sql } from 'drizzle-orm'
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
export const todoTable = sqliteTable('todo', {
/** 主键 UUID */
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
/** 待办事项标题 */
title: text('title').notNull(),
/** 是否已完成 (SQLite 用 0/1 表示布尔值) */
completed: integer('completed', { mode: 'boolean' }).notNull().default(false),
/** 创建时间 (Unix 时间戳) */
createdAt: integer('created_at', { mode: 'timestamp' })
.notNull()
.default(sql`(unixepoch())`),
/** 更新时间 (Unix 时间戳,自动更新) */
updatedAt: integer('updated_at', { mode: 'timestamp' })
.notNull()
.default(sql`(unixepoch())`)
.$onUpdateFn(() => new Date()),
})

View File

@@ -0,0 +1,27 @@
/**
* Token 使用量历史记录表
*
* 存储每次获取的 AI 模型配额使用情况
*/
import { sql } from 'drizzle-orm'
import { integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core'
export const usageHistoryTable = sqliteTable('usage_history', {
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
/** 账户名称 */
account: text('account').notNull(),
/** 模型标识符 */
model: text('model').notNull(),
/** 模型显示名称 */
displayName: text('display_name'),
/** 剩余配额百分比 (0-1) */
remainingFraction: real('remaining_fraction').notNull(),
/** 配额重置时间 (ISO 8601) */
resetTime: text('reset_time'),
/** 记录时间 */
recordedAt: integer('recorded_at', { mode: 'timestamp' })
.notNull()
.default(sql`(unixepoch())`),
})

View File

@@ -1,12 +1,83 @@
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 */
const DEFAULT_TOKEN_USAGE_URL = 'http://10.0.1.1:8318/usage'
/**
* 从同目录的 .env 配置文件读取环境变量
*
* 优先级:
* 1. 系统环境变量 (process.env)
* 2. 可执行文件同目录的 .env 文件
* 3. 默认值
*/
function loadEnvFromFile(): Record<string, string> {
const result: Record<string, string> = {}
// 确定可执行文件所在目录
const execPath = process.execPath
const isBundled = !execPath.includes('node') && !execPath.includes('bun')
const baseDir = isBundled ? dirname(execPath) : process.cwd()
const envPath = join(baseDir, '.env')
// 如果 .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
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 {
// 忽略读取错误
}
}
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
}
export const env = createEnv({
server: {},
server: {
TOKEN_USAGE_URL: z.string().url(),
},
clientPrefix: 'VITE_',
client: {
VITE_APP_TITLE: z.string().min(1).optional(),
},
runtimeEnv: process.env,
runtimeEnv: mergedEnv,
emptyStringAsUndefined: true,
})

77
src/hooks/useCountdown.ts Normal file
View File

@@ -0,0 +1,77 @@
/**
* 倒计时 Hook
*
* 计算目标时间到当前时间的剩余时间,每秒更新一次
*/
import { useEffect, useState } from 'react'
export interface CountdownResult {
/** 剩余总秒数 */
totalSeconds: number
/** 剩余小时 */
hours: number
/** 剩余分钟 */
minutes: number
/** 剩余秒 */
seconds: number
/** 格式化的倒计时字符串 (如 "2h 15m" 或 "45m 30s") */
formatted: string
/** 是否已过期 */
isExpired: boolean
}
/**
* 计算并实时更新目标时间的倒计时
* @param targetTime ISO 8601 格式的目标时间字符串
* @returns 倒计时结果
*/
export const useCountdown = (
targetTime: string | undefined,
): CountdownResult => {
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
const timer = setInterval(() => {
setNow(Date.now())
}, 1000)
return () => clearInterval(timer)
}, [])
if (!targetTime) {
return {
totalSeconds: 0,
hours: 0,
minutes: 0,
seconds: 0,
formatted: '--:--',
isExpired: true,
}
}
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,
}
}

63
src/hooks/useTheme.ts Normal file
View File

@@ -0,0 +1,63 @@
/**
* OpenBridge 主题管理 Hook
*
* 管理 data-obc-theme 属性,支持 day/dusk/night/bright 四种主题
* 主题选择会持久化到 localStorage
*/
import { useCallback, useEffect, useState } from 'react'
export type ObcTheme = 'day' | 'dusk' | 'night' | 'bright'
const STORAGE_KEY = 'obc-theme'
const DEFAULT_THEME: ObcTheme = 'day'
/**
* 获取初始主题(从 localStorage 或默认值)
*/
const getInitialTheme = (): ObcTheme => {
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
}
/**
* 管理 OpenBridge 主题切换
*/
export const useTheme = () => {
const [theme, setThemeState] = useState<ObcTheme>(getInitialTheme)
// 应用主题到 DOM
useEffect(() => {
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('data-obc-theme', theme)
localStorage.setItem(STORAGE_KEY, theme)
}
}, [theme])
// 切换到指定主题
const setTheme = useCallback((newTheme: ObcTheme) => {
setThemeState(newTheme)
}, [])
// 循环切换主题
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])
return {
theme,
setTheme,
cycleTheme,
}
}

View File

@@ -45,40 +45,7 @@ const client: RouterClient = getORPCClient()
* 使用方式:
* ```tsx
* // 查询
* const { data } = useSuspenseQuery(orpc.todo.list.queryOptions())
*
* // 变更
* const mutation = useMutation(orpc.todo.create.mutationOptions())
* mutation.mutate({ title: '新任务' })
* const { data } = useSuspenseQuery(orpc.usage.getUsage.queryOptions())
* ```
*
* 配置了自动缓存失效: 创建/更新/删除操作后自动刷新列表
*/
export const orpc = createTanstackQueryUtils(client, {
// 配置 mutation 成功后自动刷新相关查询
experimental_defaults: {
todo: {
create: {
mutationOptions: {
onSuccess: (_, __, ___, ctx) => {
ctx.client.invalidateQueries({ queryKey: orpc.todo.list.key() })
},
},
},
update: {
mutationOptions: {
onSuccess: (_, __, ___, ctx) => {
ctx.client.invalidateQueries({ queryKey: orpc.todo.list.key() })
},
},
},
remove: {
mutationOptions: {
onSuccess: (_, __, ___, ctx) => {
ctx.client.invalidateQueries({ queryKey: orpc.todo.list.key() })
},
},
},
},
},
})
export const orpc = createTanstackQueryUtils(client)

View File

@@ -1,5 +1,5 @@
import * as todo from './contracts/todo'
import * as usage from './contracts/usage'
export const contract = {
todo,
usage,
}

View File

@@ -1,60 +0,0 @@
/**
* Todo API 契约
*
* 使用 ORPC 契约定义 API 的输入/输出类型。
* drizzle-zod 自动从表 schema 生成验证规则。
*/
import { oc } from '@orpc/contract'
import {
createInsertSchema,
createSelectSchema,
createUpdateSchema,
} from 'drizzle-zod'
import { z } from 'zod'
import { todoTable } from '@/db/schema'
/** 查询返回的完整 Todo 类型 */
const selectSchema = createSelectSchema(todoTable)
/** 创建 Todo 时的输入类型 (排除自动生成的字段) */
const insertSchema = createInsertSchema(todoTable).omit({
id: true,
createdAt: true,
updatedAt: true,
})
/** 更新 Todo 时的输入类型 (所有字段可选) */
const updateSchema = createUpdateSchema(todoTable).omit({
id: true,
createdAt: true,
updatedAt: true,
})
// ============================================================
// API 契约定义
// ============================================================
/** 获取所有 Todo */
export const list = oc.input(z.void()).output(z.array(selectSchema))
/** 创建新 Todo */
export const create = oc.input(insertSchema).output(selectSchema)
/** 更新 Todo */
export const update = oc
.input(
z.object({
id: z.uuid(),
data: updateSchema,
}),
)
.output(selectSchema)
/** 删除 Todo */
export const remove = oc
.input(
z.object({
id: z.uuid(),
}),
)
.output(z.void())

View File

@@ -0,0 +1,31 @@
/**
* Token 使用量契约定义
*/
import { oc } from '@orpc/contract'
import { z } from 'zod'
/** 单个模型的使用量数据 */
const ModelUsageSchema = z.object({
/** 账户名称 */
account: z.string(),
/** 模型标识符 */
model: z.string(),
/** 模型显示名称 */
displayName: z.string().optional(),
/** 剩余配额百分比 (0-1) */
remainingFraction: z.number().min(0).max(1),
/** 配额重置时间 (ISO 8601) */
resetTime: z.string().optional(),
})
export type ModelUsage = z.infer<typeof ModelUsageSchema>
/** 获取当前使用量 */
export const getUsage = oc.output(
z.object({
/** 筛选出的 Opus/Thinking 模型列表 */
opusModels: z.array(ModelUsageSchema),
/** 数据获取时间 */
fetchedAt: z.string(),
}),
)

View File

@@ -1,75 +0,0 @@
/**
* Todo API 处理器
*
* 实现 Todo CRUD 操作的业务逻辑。
* 每个处理器都使用 dbProvider 中间件获取数据库连接。
*/
import { ORPCError } from '@orpc/server'
import { eq } from 'drizzle-orm'
import { todoTable } from '@/db/schema'
import { dbProvider } from '@/orpc/middlewares'
import { os } from '@/orpc/server'
/**
* 获取所有 Todo
*
* 按创建时间倒序排列 (最新的在前)
*/
export const list = os.todo.list
.use(dbProvider)
.handler(async ({ context }) => {
const todos = await context.db.query.todoTable.findMany({
orderBy: (todos, { desc }) => [desc(todos.createdAt)],
})
return todos
})
/**
* 创建新 Todo
*
* @throws ORPCError NOT_FOUND - 创建失败时
*/
export const create = os.todo.create
.use(dbProvider)
.handler(async ({ context, input }) => {
const [newTodo] = await context.db
.insert(todoTable)
.values(input)
.returning()
if (!newTodo) {
throw new ORPCError('NOT_FOUND')
}
return newTodo
})
/**
* 更新 Todo
*
* @throws ORPCError NOT_FOUND - Todo 不存在时
*/
export const update = os.todo.update
.use(dbProvider)
.handler(async ({ context, input }) => {
const [updatedTodo] = await context.db
.update(todoTable)
.set(input.data)
.where(eq(todoTable.id, input.id))
.returning()
if (!updatedTodo) {
throw new ORPCError('NOT_FOUND')
}
return updatedTodo
})
/**
* 删除 Todo
*/
export const remove = os.todo.remove
.use(dbProvider)
.handler(async ({ context, input }) => {
await context.db.delete(todoTable).where(eq(todoTable.id, input.id))
})

View File

@@ -0,0 +1,84 @@
/**
* Token 使用量处理器
*
* 从远程 API 获取数据,筛选 Opus/Thinking 模型,并存储历史记录
*/
import { usageHistoryTable } from '@/db/schema'
import { env } from '@/env'
import { dbProvider } from '@/orpc/middlewares'
import { os } from '@/orpc/server'
/** 远程 API 响应中的模型数据结构 */
interface RemoteModelData {
model: string
displayName?: string
remainingFraction: number
resetTime?: string
}
/** 远程 API 响应结构 */
interface RemoteResponse {
result: Record<string, RemoteModelData[]>
}
export const getUsage = os.usage.getUsage
.use(dbProvider)
.handler(async ({ context }) => {
// 1. 获取远程数据
const response = await fetch(env.TOKEN_USAGE_URL)
if (!response.ok) {
throw new Error(`Failed to fetch usage data: ${response.statusText}`)
}
const data = (await response.json()) as RemoteResponse
// 2. 解析并筛选每个账户的 claude-opus-4-5-thinking 模型
const opusModels: Array<{
account: string
model: string
displayName?: string
remainingFraction: number
resetTime?: string
}> = []
for (const [accountFile, models] of Object.entries(data.result)) {
const account = accountFile.replace('.json', '')
// 只找 claude-opus-4-5-thinking 模型
const opusModel = models.find(
(m) => m.model === 'claude-opus-4-5-thinking',
)
if (opusModel) {
opusModels.push({
account,
model: opusModel.model,
displayName: opusModel.displayName,
remainingFraction: opusModel.remainingFraction,
resetTime: opusModel.resetTime,
})
}
}
// 3. 存储到历史表(仅在 Bun 环境下工作)
try {
if (opusModels.length > 0 && context.db) {
await context.db.insert(usageHistoryTable).values(
opusModels.map((m) => ({
account: m.account,
model: m.model,
displayName: m.displayName,
remainingFraction: m.remainingFraction,
resetTime: m.resetTime,
})),
)
}
} catch (err) {
// 在非 Bun 环境下,数据库可能不可用,忽略错误
console.warn('Database insert skipped:', err)
}
return {
opusModels,
fetchedAt: new Date().toISOString(),
}
})

View File

@@ -3,21 +3,33 @@
*
* 为 ORPC 处理器提供数据库连接。使用单例模式管理连接,
* 避免每次请求都创建新连接。
*
* 注意: 在开发模式 (Vite + Node.js) 下,数据库不可用,
* 因为 bun:sqlite 只在 Bun 运行时可用。
*/
import { os } from '@orpc/server'
import { createDb, type Db } from '@/db'
import type { Db } from '@/db'
/** 全局数据库实例 (单例模式) */
let globalDb: Db | null = null
/** 是否在 Bun 环境中运行 */
const isBun = typeof globalThis.Bun !== 'undefined'
/**
* 获取数据库实例
*
* 首次调用时创建连接,后续调用返回同一实例。
* 这种模式适合长时间运行的服务器进程
* 在非 Bun 环境下返回 null
*/
function getDb(): Db {
function getDb(): Db | null {
if (!isBun) {
return null
}
if (!globalDb) {
// 动态导入以避免在 Node.js 环境下解析 bun:sqlite
const { createDb } = require('@/db')
globalDb = createDb()
}
return globalDb
@@ -31,8 +43,10 @@ function getDb(): Db {
* export const list = os.todo.list
* .use(dbProvider)
* .handler(async ({ context }) => {
* // context.db 可
* return context.db.query.todoTable.findMany()
* // context.db 可能为 null (在开发模式下)
* if (context.db) {
* return context.db.query.todoTable.findMany()
* }
* })
* ```
*/

View File

@@ -1,6 +1,6 @@
import * as todo from './handlers/todo'
import * as usage from './handlers/usage'
import { os } from './server'
export const router = os.router({
todo,
usage,
})

View File

@@ -1,4 +1,4 @@
import { TanStackDevtools } from '@tanstack/react-devtools'
import '@oicl/openbridge-webcomponents/src/palettes/variables.css'
import type { QueryClient } from '@tanstack/react-query'
import {
createRootRouteWithContext,
@@ -8,8 +8,6 @@ import {
import type { ReactNode } from 'react'
import { ErrorComponent } from '@/components/Error'
import { NotFoundComponent } from '@/components/NotFount'
import { devtools as queryDevtools } from '@/integrations/tanstack-query'
import { devtools as routerDevtools } from '@/integrations/tanstack-router'
import appCss from '@/styles.css?url'
export interface RouterContext {
@@ -27,7 +25,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
content: 'width=device-width, initial-scale=1',
},
{
title: 'Fullstack Starter',
title: 'Token Usage Viewer',
},
],
links: [
@@ -44,18 +42,12 @@ export const Route = createRootRouteWithContext<RouterContext>()({
function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
return (
<html lang="zh-Hans">
<html lang="zh-Hans" data-obc-theme="day">
<head>
<HeadContent />
</head>
<body>
<body className="obc-component-size-regular">
{children}
<TanStackDevtools
config={{
position: 'bottom-right',
}}
plugins={[routerDevtools, queryDevtools]}
/>
<Scripts />
</body>
</html>

View File

@@ -1,215 +1,36 @@
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
/**
* Token Usage Viewer 主页面
*
* 展示 Opus/Thinking 模型的配额使用情况仪表盘
*/
import { useSuspenseQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { isTauri } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import type { ChangeEventHandler, FormEventHandler } from 'react'
import { useEffect, useState } from 'react'
import { useEffect } from 'react'
import { TokenUsageDashboard } from '@/components/TokenUsageDashboard'
import { orpc } from '@/orpc'
export const Route = createFileRoute('/')({
component: Todos,
component: Home,
loader: async ({ context }) => {
await context.queryClient.ensureQueryData(orpc.todo.list.queryOptions())
await context.queryClient.ensureQueryData(
orpc.usage.getUsage.queryOptions(),
)
},
})
function Todos() {
const [newTodoTitle, setNewTodoTitle] = useState('')
const listQuery = useSuspenseQuery(orpc.todo.list.queryOptions())
const createMutation = useMutation(orpc.todo.create.mutationOptions())
const updateMutation = useMutation(orpc.todo.update.mutationOptions())
const deleteMutation = useMutation(orpc.todo.remove.mutationOptions())
function Home() {
const { data } = useSuspenseQuery({
...orpc.usage.getUsage.queryOptions(),
refetchInterval: 300000, // 每 5 分钟自动刷新
})
// 设置 Tauri 窗口标题
useEffect(() => {
if (!isTauri()) return
getCurrentWindow().setTitle('待办事项')
getCurrentWindow().setTitle('Token Usage Viewer')
}, [])
const handleCreateTodo: FormEventHandler<HTMLFormElement> = (e) => {
e.preventDefault()
if (newTodoTitle.trim()) {
createMutation.mutate({ title: newTodoTitle.trim() })
setNewTodoTitle('')
}
}
const handleInputChange: ChangeEventHandler<HTMLInputElement> = (e) => {
setNewTodoTitle(e.target.value)
}
const handleToggleTodo = (id: string, currentCompleted: boolean) => {
updateMutation.mutate({
id,
data: { completed: !currentCompleted },
})
}
const handleDeleteTodo = (id: string) => {
deleteMutation.mutate({ id })
}
const todos = listQuery.data
const completedCount = todos.filter((todo) => todo.completed).length
const totalCount = todos.length
const progress = totalCount > 0 ? (completedCount / totalCount) * 100 : 0
return (
<div className="min-h-screen bg-slate-50 py-12 px-4 sm:px-6 font-sans">
<div className="max-w-2xl mx-auto space-y-8">
{/* Header */}
<div className="flex items-end justify-between">
<div>
<h1 className="text-3xl font-bold text-slate-900 tracking-tight">
</h1>
<p className="text-slate-500 mt-1"></p>
</div>
<div className="text-right">
<div className="text-2xl font-semibold text-slate-900">
{completedCount}
<span className="text-slate-400 text-lg">/{totalCount}</span>
</div>
<div className="text-xs font-medium text-slate-400 uppercase tracking-wider">
</div>
</div>
</div>
{/* Add Todo Form */}
<form onSubmit={handleCreateTodo} className="relative group z-10">
<div className="relative transform transition-all duration-200 focus-within:-translate-y-1">
<input
type="text"
value={newTodoTitle}
onChange={handleInputChange}
placeholder="添加新任务..."
className="w-full pl-6 pr-32 py-5 bg-white rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.04)] border-0 ring-1 ring-slate-100 focus:ring-2 focus:ring-indigo-500/50 outline-none transition-all placeholder:text-slate-400 text-lg text-slate-700"
disabled={createMutation.isPending}
/>
<button
type="submit"
disabled={createMutation.isPending || !newTodoTitle.trim()}
className="absolute right-3 top-3 bottom-3 px-6 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl font-medium transition-all shadow-md shadow-indigo-200 disabled:opacity-50 disabled:shadow-none hover:shadow-lg hover:shadow-indigo-300 active:scale-95"
>
{createMutation.isPending ? '添加中' : '添加'}
</button>
</div>
</form>
{/* Progress Bar (Only visible when there are tasks) */}
{totalCount > 0 && (
<div className="h-1.5 w-full bg-slate-200 rounded-full overflow-hidden">
<div
className="h-full bg-indigo-500 transition-all duration-500 ease-out rounded-full"
style={{ width: `${progress}%` }}
/>
</div>
)}
{/* Todo List */}
<div className="space-y-3">
{todos.length === 0 ? (
<div className="py-20 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-slate-100 mb-4">
<svg
className="w-8 h-8 text-slate-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
</div>
<p className="text-slate-500 text-lg font-medium"></p>
<p className="text-slate-400 text-sm mt-1">
</p>
</div>
) : (
todos.map((todo) => (
<div
key={todo.id}
className={`group relative flex items-center p-4 bg-white rounded-xl border border-slate-100 shadow-sm transition-all duration-200 hover:shadow-md hover:border-slate-200 ${
todo.completed ? 'bg-slate-50/50' : ''
}`}
>
<button
type="button"
onClick={() => handleToggleTodo(todo.id, todo.completed)}
className={`flex-shrink-0 w-6 h-6 rounded-full border-2 transition-all duration-200 flex items-center justify-center mr-4 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 ${
todo.completed
? 'bg-indigo-500 border-indigo-500'
: 'border-slate-300 hover:border-indigo-500 bg-white'
}`}
>
{todo.completed && (
<svg
className="w-3.5 h-3.5 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
)}
</button>
<div className="flex-1 min-w-0">
<p
className={`text-lg transition-all duration-200 truncate ${
todo.completed
? 'text-slate-400 line-through decoration-slate-300 decoration-2'
: 'text-slate-700'
}`}
>
{todo.title}
</p>
</div>
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity duration-200 absolute right-4 pl-4 bg-gradient-to-l from-white via-white to-transparent sm:static sm:bg-none">
<span className="text-xs text-slate-400 mr-3 hidden sm:inline-block">
{new Date(todo.createdAt).toLocaleDateString('zh-CN')}
</span>
<button
type="button"
onClick={() => handleDeleteTodo(todo.id)}
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors focus:outline-none"
title="删除"
>
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
))
)}
</div>
</div>
</div>
)
return <TokenUsageDashboard data={data} />
}