From a77fcdd3dc0f83babf20e2efd2e44a675f72a14d Mon Sep 17 00:00:00 2001 From: MAO Dongyang Date: Wed, 21 Jan 2026 14:21:43 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=8F=81=20Final=20commit:=20Project=20Toke?= =?UTF-8?q?n=20Usage=20Viewer=20completed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 +- README.md | 363 ++++++++++++------------- package.json | 5 + src-tauri/src/sidecar.rs | 101 +++++-- src/components/HealthRing.tsx | 128 +++++++++ src/components/TokenUsageDashboard.tsx | 291 ++++++++++++++++++++ src/db/index.ts | 14 +- src/db/schema/index.ts | 2 +- src/db/schema/todo.ts | 33 --- src/db/schema/usage-history.ts | 27 ++ src/env.ts | 75 ++++- src/hooks/useCountdown.ts | 77 ++++++ src/hooks/useTheme.ts | 63 +++++ src/orpc/client.ts | 37 +-- src/orpc/contract.ts | 4 +- src/orpc/contracts/todo.ts | 60 ---- src/orpc/contracts/usage.ts | 31 +++ src/orpc/handlers/todo.ts | 75 ----- src/orpc/handlers/usage.ts | 84 ++++++ src/orpc/middlewares/db.ts | 24 +- src/orpc/router.ts | 4 +- src/routes/__root.tsx | 16 +- src/routes/index.tsx | 219 ++------------- vite.config.ts | 3 + 24 files changed, 1087 insertions(+), 651 deletions(-) create mode 100644 src/components/HealthRing.tsx create mode 100644 src/components/TokenUsageDashboard.tsx delete mode 100644 src/db/schema/todo.ts create mode 100644 src/db/schema/usage-history.ts create mode 100644 src/hooks/useCountdown.ts create mode 100644 src/hooks/useTheme.ts delete mode 100644 src/orpc/contracts/todo.ts create mode 100644 src/orpc/contracts/usage.ts delete mode 100644 src/orpc/handlers/todo.ts create mode 100644 src/orpc/handlers/usage.ts diff --git a/.env.example b/.env.example index 5f05b75..3ec2247 100644 --- a/.env.example +++ b/.env.example @@ -1 +1 @@ -DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres +TOKEN_USAGE_URL= diff --git a/README.md b/README.md index 5a8e1c9..fa3ec12 100644 --- a/README.md +++ b/README.md @@ -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 | | 样式 | Tailwind CSS v4 | -| RPC 通信 | ORPC (类型安全,契约优先) | -| 数据库 | SQLite (Bun 内置) + Drizzle ORM | +| RPC 通信 | ORPC (类型安全) | | 桌面壳 | Tauri v2 | | 运行时 | Bun | | 构建 | Vite + Turbo | @@ -22,22 +33,19 @@ ### 前置要求 - [Bun](https://bun.sh/) >= 1.0 -- [Rust](https://www.rust-lang.org/) (仅 Tauri 桌面应用需要) +- [Rust](https://www.rust-lang.org/) (Tauri 桌面应用需要) ### 安装与运行 ```bash # 1. 克隆项目 git clone -cd fullstack-starter-SQLite +cd openbridge-token-usage-viewer # 2. 安装依赖 bun install -# 3. 初始化数据库 -bun run db:init - -# 4. 启动开发服务器 +# 3. 启动开发服务器 bun run dev:vite # 仅 Web (http://localhost:3000) bun run dev # Tauri 桌面应用 + Web ``` @@ -49,194 +57,165 @@ bun run build:vite # 构建 Web 版本 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 +``` + +#### MSI 安装后无法运行 + +1. 卸载旧版本: 控制面板 → 程序和功能 → 卸载 app-desktop +2. 重新安装新的 MSI +3. 确保安装目录有写入权限 + ## 项目结构 ``` ├── src/ -│ ├── components/ # 可复用组件 -│ │ ├── Error.tsx # 错误边界组件 -│ │ └── NotFound.tsx # 404 页面组件 -│ │ -│ ├── db/ # 数据库层 -│ │ ├── index.ts # 数据库连接 -│ │ └── schema/ # Drizzle 表定义 -│ │ ├── index.ts # Schema 导出入口 -│ │ └── todo.ts # Todo 表定义 -│ │ -│ ├── orpc/ # RPC 层 (后端 API) -│ │ ├── contracts/ # 契约定义 (输入/输出 Schema) -│ │ │ └── todo.ts # Todo API 契约 -│ │ ├── 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 # 全局样式 +│ ├── components/ +│ │ ├── HealthRing.tsx # 圆环进度指示器 +│ │ ├── TokenUsageDashboard.tsx # 主仪表盘 +│ │ └── ... +│ ├── hooks/ +│ │ └── useTheme.ts # 主题切换 Hook +│ ├── orpc/ +│ │ ├── contracts/usage.ts # API 契约 +│ │ └── handlers/usage.ts # 数据获取逻辑 +│ ├── routes/ +│ │ ├── __root.tsx # 根布局 +│ │ └── index.tsx # 首页 +│ └── env.ts # 环境变量配置 │ -├── scripts/ -│ └── init-db.ts # 数据库初始化脚本 +├── src-tauri/ # Tauri 桌面应用 +│ ├── src/ +│ │ ├── lib.rs # Tauri 入口 +│ │ └── sidecar.rs # Sidecar 管理 +│ ├── binaries/ # 编译后的 sidecar +│ └── tauri.conf.json # Tauri 配置 │ -├── src-tauri/ # Tauri 桌面应用配置 -├── data/ # SQLite 数据库文件 (gitignore) -└── drizzle.config.ts # Drizzle 配置 +└── .output/ # Vite 构建输出 ``` -## 开发指南 - -### 添加新功能的步骤 - -以添加一个 "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 问题 | - -### 代码规范 - -- **格式化**: 使用 Biome,2 空格缩进,单引号 -- **导入**: 使用 `@/*` 路径别名 -- **组件**: 箭头函数组件 -- **命名**: 文件 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 diff --git a/package.json b/package.json index 953ef7e..4fcf064 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "fullstack-starter", "private": true, "type": "module", + "packageManager": "bun@1.3.6", "scripts": { "build": "turbo build:tauri", "build:compile": "bun build.ts", @@ -19,6 +20,8 @@ "typecheck": "tsc -b" }, "dependencies": { + "@effect/schema": "^0.75.5", + "@oicl/openbridge-webcomponents-react": "^0.0.17", "@orpc/client": "^1.13.4", "@orpc/contract": "^1.13.4", "@orpc/server": "^1.13.4", @@ -30,6 +33,8 @@ "@tanstack/react-router-ssr-query": "^1.151.0", "@tanstack/react-start": "^1.151.0", "@tauri-apps/api": "^2.9.1", + "@types/react": "^19.2.9", + "@types/react-dom": "^19.2.3", "drizzle-orm": "^0.45.1", "drizzle-zod": "^0.8.3", "react": "^19.2.3", diff --git a/src-tauri/src/sidecar.rs b/src-tauri/src/sidecar.rs index 879dfc1..cac00de 100644 --- a/src-tauri/src/sidecar.rs +++ b/src-tauri/src/sidecar.rs @@ -8,7 +8,7 @@ use tauri_plugin_shell::ShellExt; // ===== 配置常量 ===== /// Sidecar App 启动超时时间(秒) -const STARTUP_TIMEOUT_SECS: u64 = 5; +const STARTUP_TIMEOUT_SECS: u64 = 30; /// 默认起始端口 const DEFAULT_PORT: u16 = 3000; @@ -47,6 +47,27 @@ async fn find_available_port(start: u16) -> u16 { 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 进程并创建主窗口 pub fn spawn_sidecar(app_handle: tauri::AppHandle) { // 检测是否为开发模式 @@ -84,13 +105,23 @@ pub fn spawn_sidecar(app_handle: tauri::AppHandle) { println!("使用端口: {}", port); // 启动 sidecar - let sidecar = app_handle - .shell() - .sidecar("app") - .expect("无法找到 app") - .env("PORT", port.to_string()); + let sidecar = match app_handle.shell().sidecar("app") { + Ok(cmd) => cmd.env("PORT", port.to_string()), + Err(e) => { + eprintln!("✗ 无法找到 sidecar: {}", e); + 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::() { @@ -103,30 +134,46 @@ pub fn spawn_sidecar(app_handle: tauri::AppHandle) { let mut app_ready = false; while let Some(event) = rx.recv().await { - if let CommandEvent::Stdout(line) = event { - let output = String::from_utf8_lossy(&line); - println!("App: {}", output); + match event { + CommandEvent::Stdout(line) => { + let output = String::from_utf8_lossy(&line); + println!("App: {}", output); - // 检测 App 启动成功的标志 - if output.contains("Listening on:") || output.contains("localhost") { - app_ready = true; - println!("✓ App 启动成功!"); + // 检测 App 启动成功的标志 + if output.contains("Listening on:") || output.contains("localhost") { + app_ready = true; + println!("✓ App 启动成功!"); - // 创建主窗口 - let url = format!("http://localhost:{}", port); - tauri::WebviewWindowBuilder::new( - &app_handle, - "main", - tauri::WebviewUrl::External(url.parse().unwrap()), - ) - .title(WINDOW_TITLE) - .inner_size(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT) - .center() - .build() - .expect("创建窗口失败"); + // 创建主窗口 + let url = format!("http://localhost:{}", port); + match tauri::WebviewWindowBuilder::new( + &app_handle, + "main", + tauri::WebviewUrl::External(url.parse().unwrap()), + ) + .title(WINDOW_TITLE) + .inner_size(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT) + .center() + .build() + { + 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); + } + _ => {} } // 超时检查 diff --git a/src/components/HealthRing.tsx b/src/components/HealthRing.tsx new file mode 100644 index 0000000..2aa3efd --- /dev/null +++ b/src/components/HealthRing.tsx @@ -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 ( +
+ {/* 圆环 */} +
+ + {/* 背景圆环 */} + + {/* 进度圆环 */} + + + + {/* 中心内容 - 倒计时 */} +
+ + {percentage}% + + + {countdown.formatted} + +
+
+ + {/* 标签 */} +
+
+ {displayName || 'Claude Opus 4.5'} +
+
+ {account} +
+
+
+ ) +} diff --git a/src/components/TokenUsageDashboard.tsx b/src/components/TokenUsageDashboard.tsx new file mode 100644 index 0000000..002643c --- /dev/null +++ b/src/components/TokenUsageDashboard.tsx @@ -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 = () => ( +
+) + +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 ( +
+ {/* 顶部导航栏 */} +
+ }> + + {/* 右侧: 告警面板 */} +
+ + + {alerts.length > 0 && alerts[0] && ( + + + + + + + + + {extractUsername(alerts[0].account)}: 剩余{' '} + {Math.round(alerts[0].remainingFraction * 100)}% + + + + )} + + +
+
+
+
+ + {/* 侧边导航菜单 */} + {menuOpen && ( + + )} + + {/* 点击遮罩关闭菜单 */} + {menuOpen && ( +
+ ) +} diff --git a/src/db/index.ts b/src/db/index.ts index d9f2553..8c0e11c 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -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); `) } diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index a1cad6c..4a883fa 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -1 +1 @@ -export * from './todo' +export * from './usage-history' diff --git a/src/db/schema/todo.ts b/src/db/schema/todo.ts deleted file mode 100644 index 962cd33..0000000 --- a/src/db/schema/todo.ts +++ /dev/null @@ -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()), -}) diff --git a/src/db/schema/usage-history.ts b/src/db/schema/usage-history.ts new file mode 100644 index 0000000..fca6096 --- /dev/null +++ b/src/db/schema/usage-history.ts @@ -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())`), +}) diff --git a/src/env.ts b/src/env.ts index 8b32bdc..36e939c 100644 --- a/src/env.ts +++ b/src/env.ts @@ -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 { + const result: Record = {} + + // 确定可执行文件所在目录 + 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 = { + ...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, }) diff --git a/src/hooks/useCountdown.ts b/src/hooks/useCountdown.ts new file mode 100644 index 0000000..31a1e1d --- /dev/null +++ b/src/hooks/useCountdown.ts @@ -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, + } +} diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts new file mode 100644 index 0000000..37acef7 --- /dev/null +++ b/src/hooks/useTheme.ts @@ -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(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, + } +} diff --git a/src/orpc/client.ts b/src/orpc/client.ts index 2dbc526..750174d 100644 --- a/src/orpc/client.ts +++ b/src/orpc/client.ts @@ -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) diff --git a/src/orpc/contract.ts b/src/orpc/contract.ts index c132aa1..3e5e1ce 100644 --- a/src/orpc/contract.ts +++ b/src/orpc/contract.ts @@ -1,5 +1,5 @@ -import * as todo from './contracts/todo' +import * as usage from './contracts/usage' export const contract = { - todo, + usage, } diff --git a/src/orpc/contracts/todo.ts b/src/orpc/contracts/todo.ts deleted file mode 100644 index 55fc485..0000000 --- a/src/orpc/contracts/todo.ts +++ /dev/null @@ -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()) diff --git a/src/orpc/contracts/usage.ts b/src/orpc/contracts/usage.ts new file mode 100644 index 0000000..66d9557 --- /dev/null +++ b/src/orpc/contracts/usage.ts @@ -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 + +/** 获取当前使用量 */ +export const getUsage = oc.output( + z.object({ + /** 筛选出的 Opus/Thinking 模型列表 */ + opusModels: z.array(ModelUsageSchema), + /** 数据获取时间 */ + fetchedAt: z.string(), + }), +) diff --git a/src/orpc/handlers/todo.ts b/src/orpc/handlers/todo.ts deleted file mode 100644 index 59a23aa..0000000 --- a/src/orpc/handlers/todo.ts +++ /dev/null @@ -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)) - }) diff --git a/src/orpc/handlers/usage.ts b/src/orpc/handlers/usage.ts new file mode 100644 index 0000000..281f073 --- /dev/null +++ b/src/orpc/handlers/usage.ts @@ -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 +} + +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(), + } + }) diff --git a/src/orpc/middlewares/db.ts b/src/orpc/middlewares/db.ts index 35d2d51..bee533a 100644 --- a/src/orpc/middlewares/db.ts +++ b/src/orpc/middlewares/db.ts @@ -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() + * } * }) * ``` */ diff --git a/src/orpc/router.ts b/src/orpc/router.ts index 603f313..8778067 100644 --- a/src/orpc/router.ts +++ b/src/orpc/router.ts @@ -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, }) diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 056109b..826d520 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -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()({ content: 'width=device-width, initial-scale=1', }, { - title: 'Fullstack Starter', + title: 'Token Usage Viewer', }, ], links: [ @@ -44,18 +42,12 @@ export const Route = createRootRouteWithContext()({ function RootDocument({ children }: Readonly<{ children: ReactNode }>) { return ( - + - + {children} - diff --git a/src/routes/index.tsx b/src/routes/index.tsx index ab7ac88..99802db 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -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 = (e) => { - e.preventDefault() - if (newTodoTitle.trim()) { - createMutation.mutate({ title: newTodoTitle.trim() }) - setNewTodoTitle('') - } - } - - const handleInputChange: ChangeEventHandler = (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 ( -
-
- {/* Header */} -
-
-

- 我的待办 -

-

保持专注,逐个击破

-
-
-
- {completedCount} - /{totalCount} -
-
- 已完成 -
-
-
- - {/* Add Todo Form */} -
-
- - -
-
- - {/* Progress Bar (Only visible when there are tasks) */} - {totalCount > 0 && ( -
-
-
- )} - - {/* Todo List */} -
- {todos.length === 0 ? ( -
-
- -
-

没有待办事项

-

- 输入上方内容添加您的第一个任务 -

-
- ) : ( - todos.map((todo) => ( -
- - -
-

- {todo.title} -

-
- -
- - {new Date(todo.createdAt).toLocaleDateString('zh-CN')} - - -
-
- )) - )} -
-
-
- ) + return } diff --git a/vite.config.ts b/vite.config.ts index 8a2947d..42a0178 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,6 +8,9 @@ import tsconfigPaths from 'vite-tsconfig-paths' export default defineConfig({ clearScreen: false, + build: { + cssMinify: 'esbuild', // 使用 esbuild 替代 lightningcss 避免第三方 CSS 兼容性问题 + }, plugins: [ tanstackDevtools(), nitro({