🏁 Final commit: Project Token Usage Viewer completed

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

View File

@@ -1 +1 @@
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres TOKEN_USAGE_URL=

363
README.md
View File

@@ -1,18 +1,29 @@
# Fullstack Starter (SQLite) # Token Usage Viewer
一个基于 **TanStack Start + Bun + Tauri + SQLite**全栈桌面应用脚手架 一个基于 **TanStack Start + Bun + Tauri** 的桌面应用,用于监控 Claude API 账户的 Token 配额使用情况
包含一个完整的 **Todo List** 示例,展示了从前端到后端的完整数据流 采用 [OpenBridge Design System](https://github.com/oicl/openbridge-webcomponents) 设计规范,提供专业的航海仪表盘风格界面
## 功能特点
- **实时配额监控**: 显示 4 个账户的 Claude Opus 4.5 (Thinking) 模型配额使用情况
- **可视化仪表盘**: Apple Health 风格的圆环进度指示器
- **智能告警系统**:
- 当配额剩余 < 20% 时显示警告 (Warning)
- 当配额剩余 < 5% 时显示紧急告警 (Alarm)
- **主题切换**: 支持白天/夜间模式切换
- **自动刷新**: 配额数据每 5 分钟自动刷新
- **倒计时显示**: 显示配额重置剩余时间
## 技术栈 ## 技术栈
| 层级 | 技术 | | 层级 | 技术 |
|------|------| |------|------|
| 前端框架 | React 19 + TanStack Router (文件路由) | | 前端框架 | React 19 + TanStack Router |
| UI 组件 | OpenBridge Web Components |
| 状态管理 | TanStack Query | | 状态管理 | TanStack Query |
| 样式 | Tailwind CSS v4 | | 样式 | Tailwind CSS v4 |
| RPC 通信 | ORPC (类型安全,契约优先) | | RPC 通信 | ORPC (类型安全) |
| 数据库 | SQLite (Bun 内置) + Drizzle ORM |
| 桌面壳 | Tauri v2 | | 桌面壳 | Tauri v2 |
| 运行时 | Bun | | 运行时 | Bun |
| 构建 | Vite + Turbo | | 构建 | Vite + Turbo |
@@ -22,22 +33,19 @@
### 前置要求 ### 前置要求
- [Bun](https://bun.sh/) >= 1.0 - [Bun](https://bun.sh/) >= 1.0
- [Rust](https://www.rust-lang.org/) (Tauri 桌面应用需要) - [Rust](https://www.rust-lang.org/) (Tauri 桌面应用需要)
### 安装与运行 ### 安装与运行
```bash ```bash
# 1. 克隆项目 # 1. 克隆项目
git clone <your-repo-url> git clone <your-repo-url>
cd fullstack-starter-SQLite cd openbridge-token-usage-viewer
# 2. 安装依赖 # 2. 安装依赖
bun install bun install
# 3. 初始化数据库 # 3. 启动开发服务器
bun run db:init
# 4. 启动开发服务器
bun run dev:vite # 仅 Web (http://localhost:3000) bun run dev:vite # 仅 Web (http://localhost:3000)
bun run dev # Tauri 桌面应用 + Web bun run dev # Tauri 桌面应用 + Web
``` ```
@@ -49,194 +57,165 @@ bun run build:vite # 构建 Web 版本
bun run build # 构建 Tauri 桌面安装包 bun run build # 构建 Tauri 桌面安装包
``` ```
构建产物位置:
- **MSI 安装包**: `src-tauri/target/release/bundle/msi/app-desktop_0.1.0_x64_en-US.msi`
- **NSIS 安装包**: `src-tauri/target/release/bundle/nsis/app-desktop_0.1.0_x64-setup.exe`
## 配置
### 环境变量
应用会按以下优先级读取 `TOKEN_USAGE_URL` 配置:
1. **系统环境变量** (最高优先级)
2. **可执行文件同目录的 `.env` 文件**
3. **默认值**: `http://10.0.1.1:8318/usage`
#### 方式 1: 环境变量
```bash
# Windows PowerShell
$env:TOKEN_USAGE_URL = "http://your-server:8318/usage"
.\app-desktop.exe
# Linux/macOS
TOKEN_USAGE_URL=http://your-server:8318/usage ./app-desktop
```
#### 方式 2: 配置文件
在可执行文件同目录创建 `.env` 文件:
```env
TOKEN_USAGE_URL=http://your-server:8318/usage
```
## 常用命令
| 命令 | 说明 |
|------|------|
| `bun dev` | 启动 Tauri + Vite 开发服务器 |
| `bun dev:vite` | 仅启动 Vite 开发服务器 |
| `bun dev:tauri` | 仅启动 Tauri (需先启动 Vite) |
| `bun build` | 完整构建 (Tauri 桌面应用) |
| `bun build:vite` | 仅构建 Web 版本 |
| `bun typecheck` | TypeScript 类型检查 |
| `bun fix` | 自动修复格式和 Lint 问题 |
## 故障排除
### 清理缓存与重置
如果遇到构建问题、端口占用或缓存问题,按以下步骤重置:
#### 1. 结束所有相关进程
```powershell
# Windows PowerShell (管理员)
taskkill /F /IM "app.exe" 2>$null
taskkill /F /IM "app-desktop.exe" 2>$null
taskkill /F /IM "bun.exe" 2>$null
taskkill /F /IM "node.exe" 2>$null
```
```bash
# Linux/macOS
pkill -f "app-desktop" || true
pkill -f "bun" || true
```
#### 2. 清除所有缓存
```bash
# Turbo 缓存
rm -rf .turbo
# Vite 缓存
rm -rf node_modules/.vite
rm -rf node_modules/.cache
# 构建输出
rm -rf .output
# Tauri sidecar binaries
rm -rf src-tauri/binaries/app-*
```
#### 3. 完全清理 (可选,需要重新编译 Rust耗时 2-5 分钟)
```bash
# 清除 Rust 编译缓存
rm -rf src-tauri/target
```
#### 4. 确认端口已释放
```bash
# Windows
netstat -ano | findstr :3000
# Linux/macOS
lsof -i :3000
```
#### 5. 重新构建
```bash
bun run build
```
### 常见问题
#### "An unhandled error happened!" 错误
1. 确保没有其他进程占用端口 3000
2. 尝试完全清理缓存后重新构建
3. 检查 `.env` 文件中的 `TOKEN_USAGE_URL` 配置是否正确
#### 端口被占用 (EADDRINUSE)
```powershell
# 查找占用端口的进程
netstat -ano | findstr :3000
# 结束进程 (替换 PID 为实际进程 ID)
taskkill /F /PID <PID>
```
#### MSI 安装后无法运行
1. 卸载旧版本: 控制面板 → 程序和功能 → 卸载 app-desktop
2. 重新安装新的 MSI
3. 确保安装目录有写入权限
## 项目结构 ## 项目结构
``` ```
├── src/ ├── src/
│ ├── components/ # 可复用组件 │ ├── components/
│ │ ├── Error.tsx # 错误边界组件 │ │ ├── HealthRing.tsx # 圆环进度指示器
│ │ ── NotFound.tsx # 404 页面组件 │ │ ── TokenUsageDashboard.tsx # 主仪表盘
│ │ │ │ └── ...
│ ├── db/ # 数据库层 │ ├── hooks/
│ │ ── index.ts # 数据库连接 │ │ ── useTheme.ts # 主题切换 Hook
│ └── schema/ # Drizzle 表定义 ├── orpc/
│ │ ├── index.ts # Schema 导出入口 │ │ ├── contracts/usage.ts # API 契约
│ │ └── todo.ts # Todo 表定义 │ │ └── handlers/usage.ts # 数据获取逻辑
├── routes/
│ ├── orpc/ # RPC 层 (后端 API) │ ├── __root.tsx # 根布局
│ │ ── contracts/ # 契约定义 (输入/输出 Schema) │ │ ── index.tsx # 首页
└── todo.ts # Todo API 契约 └── env.ts # 环境变量配置
│ │ ├── handlers/ # 业务逻辑实现
│ │ │ └── todo.ts # Todo CRUD 处理器
│ │ ├── middlewares/ # 中间件
│ │ │ └── db.ts # 数据库注入中间件
│ │ ├── contract.ts # 契约聚合
│ │ ├── router.ts # 路由聚合
│ │ ├── client.ts # 同构客户端 (SSR/CSR)
│ │ ├── server.ts # 服务端实例
│ │ └── index.ts # 导出入口
│ │
│ ├── routes/ # 页面路由 (文件路由)
│ │ ├── __root.tsx # 根布局
│ │ ├── index.tsx # 首页 (Todo List)
│ │ └── api/
│ │ └── rpc.$.ts # RPC HTTP 端点
│ │
│ ├── integrations/ # 第三方库集成
│ ├── lib/ # 工具函数
│ ├── env.ts # 环境变量验证
│ ├── router.tsx # 路由配置
│ └── styles.css # 全局样式
├── scripts/ ├── src-tauri/ # Tauri 桌面应用
── init-db.ts # 数据库初始化脚本 ── src/
│ │ ├── lib.rs # Tauri 入口
│ │ └── sidecar.rs # Sidecar 管理
│ ├── binaries/ # 编译后的 sidecar
│ └── tauri.conf.json # Tauri 配置
── src-tauri/ # Tauri 桌面应用配置 ── .output/ # Vite 构建输出
├── data/ # SQLite 数据库文件 (gitignore)
└── drizzle.config.ts # Drizzle 配置
``` ```
## 开发指南
### 添加新功能的步骤
以添加一个 "Note" 功能为例:
#### 1. 定义数据库 Schema
```typescript
// src/db/schema/note.ts
import { sql } from 'drizzle-orm'
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
export const noteTable = sqliteTable('note', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
content: text('content').notNull(),
createdAt: integer('created_at', { mode: 'timestamp' })
.notNull()
.default(sql`(unixepoch())`),
})
```
```typescript
// src/db/schema/index.ts
export * from './todo.ts'
export * from './note.ts' // 添加导出
```
#### 2. 定义 API 契约
```typescript
// src/orpc/contracts/note.ts
import { oc } from '@orpc/contract'
import { z } from 'zod'
export const note = {
list: oc.output(z.array(noteSchema)),
create: oc.input(z.object({ content: z.string() })).output(noteSchema),
}
```
#### 3. 实现业务逻辑
```typescript
// src/orpc/handlers/note.ts
import { os } from '@/orpc/server'
import { dbProvider } from '@/orpc/middlewares/db'
import { noteTable } from '@/db/schema'
export const list = os.note.list
.use(dbProvider)
.handler(async ({ context }) => {
return context.db.select().from(noteTable)
})
export const create = os.note.create
.use(dbProvider)
.handler(async ({ context, input }) => {
const [note] = await context.db.insert(noteTable).values(input).returning()
return note
})
```
#### 4. 注册到路由
```typescript
// src/orpc/contract.ts
import { note } from './contracts/note'
export const contract = { todo, note }
// src/orpc/router.ts
import * as note from './handlers/note'
export const router = os.router({ todo, note })
```
#### 5. 在页面中使用
```typescript
// src/routes/notes.tsx
import { orpc } from '@/orpc'
import { useSuspenseQuery, useMutation } from '@tanstack/react-query'
const NotesPage = () => {
const { data: notes } = useSuspenseQuery(orpc.note.list.queryOptions())
const createNote = useMutation(orpc.note.create.mutationOptions())
// ...
}
```
### 常用命令
| 命令 | 说明 |
|------|------|
| `bun run dev` | 启动 Tauri + Vite 开发服务器 |
| `bun run dev:vite` | 仅启动 Vite 开发服务器 |
| `bun run build` | 完整构建 (Tauri 桌面应用) |
| `bun run build:vite` | 仅构建 Web 版本 |
| `bun run db:init` | 初始化/重置数据库 |
| `bun run db:studio` | 打开 Drizzle Studio |
| `bun run typecheck` | TypeScript 类型检查 |
| `bun run fix` | 自动修复格式和 Lint 问题 |
### 代码规范
- **格式化**: 使用 Biome2 空格缩进,单引号
- **导入**: 使用 `@/*` 路径别名
- **组件**: 箭头函数组件
- **命名**: 文件 kebab-case组件 PascalCase
## 核心概念
### ORPC 数据流
```
前端组件
↓ useSuspenseQuery / useMutation
ORPC 客户端 (src/orpc/client.ts)
↓ 自动选择 SSR 直调 / CSR HTTP
契约验证 (src/orpc/contracts/)
↓ 输入/输出类型安全
处理器 (src/orpc/handlers/)
↓ 业务逻辑
中间件 (src/orpc/middlewares/)
↓ 注入数据库连接
Drizzle ORM
↓ 类型安全查询
SQLite 数据库
```
### 同构渲染
- **SSR**: 服务端直接调用 router无 HTTP 开销
- **CSR**: 客户端通过 `/api/rpc` 端点调用
### 数据库
- SQLite 文件存储在 `./data/app.db`
- 使用 WAL 模式提高并发性能
- 单例模式管理连接
## 许可证 ## 许可证
MIT MIT

View File

@@ -2,6 +2,7 @@
"name": "fullstack-starter", "name": "fullstack-starter",
"private": true, "private": true,
"type": "module", "type": "module",
"packageManager": "bun@1.3.6",
"scripts": { "scripts": {
"build": "turbo build:tauri", "build": "turbo build:tauri",
"build:compile": "bun build.ts", "build:compile": "bun build.ts",
@@ -19,6 +20,8 @@
"typecheck": "tsc -b" "typecheck": "tsc -b"
}, },
"dependencies": { "dependencies": {
"@effect/schema": "^0.75.5",
"@oicl/openbridge-webcomponents-react": "^0.0.17",
"@orpc/client": "^1.13.4", "@orpc/client": "^1.13.4",
"@orpc/contract": "^1.13.4", "@orpc/contract": "^1.13.4",
"@orpc/server": "^1.13.4", "@orpc/server": "^1.13.4",
@@ -30,6 +33,8 @@
"@tanstack/react-router-ssr-query": "^1.151.0", "@tanstack/react-router-ssr-query": "^1.151.0",
"@tanstack/react-start": "^1.151.0", "@tanstack/react-start": "^1.151.0",
"@tauri-apps/api": "^2.9.1", "@tauri-apps/api": "^2.9.1",
"@types/react": "^19.2.9",
"@types/react-dom": "^19.2.3",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"drizzle-zod": "^0.8.3", "drizzle-zod": "^0.8.3",
"react": "^19.2.3", "react": "^19.2.3",

View File

@@ -8,7 +8,7 @@ use tauri_plugin_shell::ShellExt;
// ===== 配置常量 ===== // ===== 配置常量 =====
/// Sidecar App 启动超时时间(秒) /// Sidecar App 启动超时时间(秒)
const STARTUP_TIMEOUT_SECS: u64 = 5; const STARTUP_TIMEOUT_SECS: u64 = 30;
/// 默认起始端口 /// 默认起始端口
const DEFAULT_PORT: u16 = 3000; const DEFAULT_PORT: u16 = 3000;
@@ -47,6 +47,27 @@ async fn find_available_port(start: u16) -> u16 {
start // 回退到起始端口 start // 回退到起始端口
} }
// 显示错误对话框
fn show_error_dialog(message: &str) {
#[cfg(target_os = "windows")]
{
use std::process::Command;
let _ = Command::new("powershell")
.args([
"-Command",
&format!(
"[System.Windows.Forms.MessageBox]::Show('{}', '启动错误', 'OK', 'Error')",
message.replace('\'', "''")
),
])
.spawn();
}
#[cfg(not(target_os = "windows"))]
{
eprintln!("错误: {}", message);
}
}
/// 启动 Sidecar 进程并创建主窗口 /// 启动 Sidecar 进程并创建主窗口
pub fn spawn_sidecar(app_handle: tauri::AppHandle) { pub fn spawn_sidecar(app_handle: tauri::AppHandle) {
// 检测是否为开发模式 // 检测是否为开发模式
@@ -84,13 +105,23 @@ pub fn spawn_sidecar(app_handle: tauri::AppHandle) {
println!("使用端口: {}", port); println!("使用端口: {}", port);
// 启动 sidecar // 启动 sidecar
let sidecar = app_handle let sidecar = match app_handle.shell().sidecar("app") {
.shell() Ok(cmd) => cmd.env("PORT", port.to_string()),
.sidecar("app") Err(e) => {
.expect("无法找到 app") eprintln!("无法找到 sidecar: {}", e);
.env("PORT", port.to_string()); show_error_dialog("无法找到后端服务程序,请重新安装应用。");
std::process::exit(1);
}
};
let (mut rx, child) = sidecar.spawn().expect("启动 sidecar 失败"); let (mut rx, child) = match sidecar.spawn() {
Ok(result) => result,
Err(e) => {
eprintln!("✗ 启动 sidecar 失败: {}", e);
show_error_dialog(&format!("后端服务启动失败: {}", e));
std::process::exit(1);
}
};
// 保存进程句柄到全局状态 // 保存进程句柄到全局状态
if let Some(state) = app_handle.try_state::<SidecarProcess>() { if let Some(state) = app_handle.try_state::<SidecarProcess>() {
@@ -103,30 +134,46 @@ pub fn spawn_sidecar(app_handle: tauri::AppHandle) {
let mut app_ready = false; let mut app_ready = false;
while let Some(event) = rx.recv().await { while let Some(event) = rx.recv().await {
if let CommandEvent::Stdout(line) = event { match event {
let output = String::from_utf8_lossy(&line); CommandEvent::Stdout(line) => {
println!("App: {}", output); let output = String::from_utf8_lossy(&line);
println!("App: {}", output);
// 检测 App 启动成功的标志 // 检测 App 启动成功的标志
if output.contains("Listening on:") || output.contains("localhost") { if output.contains("Listening on:") || output.contains("localhost") {
app_ready = true; app_ready = true;
println!("✓ App 启动成功!"); println!("✓ App 启动成功!");
// 创建主窗口 // 创建主窗口
let url = format!("http://localhost:{}", port); let url = format!("http://localhost:{}", port);
tauri::WebviewWindowBuilder::new( match tauri::WebviewWindowBuilder::new(
&app_handle, &app_handle,
"main", "main",
tauri::WebviewUrl::External(url.parse().unwrap()), tauri::WebviewUrl::External(url.parse().unwrap()),
) )
.title(WINDOW_TITLE) .title(WINDOW_TITLE)
.inner_size(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT) .inner_size(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT)
.center() .center()
.build() .build()
.expect("创建窗口失败"); {
Ok(_) => println!("✓ 窗口创建成功"),
Err(e) => {
eprintln!("✗ 窗口创建失败: {}", e);
show_error_dialog(&format!("窗口创建失败: {}", e));
}
}
break; break;
}
} }
CommandEvent::Stderr(line) => {
let output = String::from_utf8_lossy(&line);
eprintln!("App Error: {}", output);
}
CommandEvent::Error(e) => {
eprintln!("Sidecar 错误: {}", e);
}
_ => {}
} }
// 超时检查 // 超时检查

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) { function initTables(sqlite: Database) {
sqlite.exec(` sqlite.exec(`
CREATE TABLE IF NOT EXISTS todo ( CREATE TABLE IF NOT EXISTS usage_history (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
title TEXT NOT NULL, account TEXT NOT NULL,
completed INTEGER NOT NULL DEFAULT 0, model TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch()), display_name TEXT,
updated_at INTEGER NOT NULL DEFAULT (unixepoch()) 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 { createEnv } from '@t3-oss/env-core'
import { z } from 'zod' 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({ export const env = createEnv({
server: {}, server: {
TOKEN_USAGE_URL: z.string().url(),
},
clientPrefix: 'VITE_', clientPrefix: 'VITE_',
client: { client: {
VITE_APP_TITLE: z.string().min(1).optional(), VITE_APP_TITLE: z.string().min(1).optional(),
}, },
runtimeEnv: process.env, runtimeEnv: mergedEnv,
emptyStringAsUndefined: true, 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 * ```tsx
* // 查询 * // 查询
* const { data } = useSuspenseQuery(orpc.todo.list.queryOptions()) * const { data } = useSuspenseQuery(orpc.usage.getUsage.queryOptions())
*
* // 变更
* const mutation = useMutation(orpc.todo.create.mutationOptions())
* mutation.mutate({ title: '新任务' })
* ``` * ```
*
* 配置了自动缓存失效: 创建/更新/删除操作后自动刷新列表
*/ */
export const orpc = createTanstackQueryUtils(client, { 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() })
},
},
},
},
},
})

View File

@@ -1,5 +1,5 @@
import * as todo from './contracts/todo' import * as usage from './contracts/usage'
export const contract = { 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 处理器提供数据库连接。使用单例模式管理连接, * 为 ORPC 处理器提供数据库连接。使用单例模式管理连接,
* 避免每次请求都创建新连接。 * 避免每次请求都创建新连接。
*
* 注意: 在开发模式 (Vite + Node.js) 下,数据库不可用,
* 因为 bun:sqlite 只在 Bun 运行时可用。
*/ */
import { os } from '@orpc/server' import { os } from '@orpc/server'
import { createDb, type Db } from '@/db' import type { Db } from '@/db'
/** 全局数据库实例 (单例模式) */ /** 全局数据库实例 (单例模式) */
let globalDb: Db | null = null 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) { if (!globalDb) {
// 动态导入以避免在 Node.js 环境下解析 bun:sqlite
const { createDb } = require('@/db')
globalDb = createDb() globalDb = createDb()
} }
return globalDb return globalDb
@@ -31,8 +43,10 @@ function getDb(): Db {
* export const list = os.todo.list * export const list = os.todo.list
* .use(dbProvider) * .use(dbProvider)
* .handler(async ({ context }) => { * .handler(async ({ context }) => {
* // context.db 可 * // context.db 可能为 null (在开发模式下)
* return context.db.query.todoTable.findMany() * 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' import { os } from './server'
export const router = os.router({ 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 type { QueryClient } from '@tanstack/react-query'
import { import {
createRootRouteWithContext, createRootRouteWithContext,
@@ -8,8 +8,6 @@ import {
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { ErrorComponent } from '@/components/Error' import { ErrorComponent } from '@/components/Error'
import { NotFoundComponent } from '@/components/NotFount' 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' import appCss from '@/styles.css?url'
export interface RouterContext { export interface RouterContext {
@@ -27,7 +25,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
content: 'width=device-width, initial-scale=1', content: 'width=device-width, initial-scale=1',
}, },
{ {
title: 'Fullstack Starter', title: 'Token Usage Viewer',
}, },
], ],
links: [ links: [
@@ -44,18 +42,12 @@ export const Route = createRootRouteWithContext<RouterContext>()({
function RootDocument({ children }: Readonly<{ children: ReactNode }>) { function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
return ( return (
<html lang="zh-Hans"> <html lang="zh-Hans" data-obc-theme="day">
<head> <head>
<HeadContent /> <HeadContent />
</head> </head>
<body> <body className="obc-component-size-regular">
{children} {children}
<TanStackDevtools
config={{
position: 'bottom-right',
}}
plugins={[routerDevtools, queryDevtools]}
/>
<Scripts /> <Scripts />
</body> </body>
</html> </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 { createFileRoute } from '@tanstack/react-router'
import { isTauri } from '@tauri-apps/api/core' import { isTauri } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window' import { getCurrentWindow } from '@tauri-apps/api/window'
import type { ChangeEventHandler, FormEventHandler } from 'react' import { useEffect } from 'react'
import { useEffect, useState } from 'react' import { TokenUsageDashboard } from '@/components/TokenUsageDashboard'
import { orpc } from '@/orpc' import { orpc } from '@/orpc'
export const Route = createFileRoute('/')({ export const Route = createFileRoute('/')({
component: Todos, component: Home,
loader: async ({ context }) => { loader: async ({ context }) => {
await context.queryClient.ensureQueryData(orpc.todo.list.queryOptions()) await context.queryClient.ensureQueryData(
orpc.usage.getUsage.queryOptions(),
)
}, },
}) })
function Todos() { function Home() {
const [newTodoTitle, setNewTodoTitle] = useState('') const { data } = useSuspenseQuery({
...orpc.usage.getUsage.queryOptions(),
const listQuery = useSuspenseQuery(orpc.todo.list.queryOptions()) refetchInterval: 300000, // 每 5 分钟自动刷新
const createMutation = useMutation(orpc.todo.create.mutationOptions()) })
const updateMutation = useMutation(orpc.todo.update.mutationOptions())
const deleteMutation = useMutation(orpc.todo.remove.mutationOptions())
// 设置 Tauri 窗口标题
useEffect(() => { useEffect(() => {
if (!isTauri()) return if (!isTauri()) return
getCurrentWindow().setTitle('待办事项') getCurrentWindow().setTitle('Token Usage Viewer')
}, []) }, [])
const handleCreateTodo: FormEventHandler<HTMLFormElement> = (e) => { return <TokenUsageDashboard data={data} />
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>
)
} }

View File

@@ -8,6 +8,9 @@ import tsconfigPaths from 'vite-tsconfig-paths'
export default defineConfig({ export default defineConfig({
clearScreen: false, clearScreen: false,
build: {
cssMinify: 'esbuild', // 使用 esbuild 替代 lightningcss 避免第三方 CSS 兼容性问题
},
plugins: [ plugins: [
tanstackDevtools(), tanstackDevtools(),
nitro({ nitro({