🏁 Final commit: Project Token Usage Viewer completed
This commit is contained in:
@@ -1 +1 @@
|
|||||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
|
TOKEN_USAGE_URL=
|
||||||
|
|||||||
361
README.md
361
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 |
|
| 状态管理 | 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)
|
|
||||||
│ │ ├── 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 # 根布局
|
│ │ ├── __root.tsx # 根布局
|
||||||
│ │ ├── index.tsx # 首页 (Todo List)
|
│ │ └── index.tsx # 首页
|
||||||
│ │ └── api/
|
│ └── env.ts # 环境变量配置
|
||||||
│ │ └── 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 问题 |
|
|
||||||
|
|
||||||
### 代码规范
|
|
||||||
|
|
||||||
- **格式化**: 使用 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
|
MIT
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,7 +134,8 @@ 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 {
|
||||||
|
CommandEvent::Stdout(line) => {
|
||||||
let output = String::from_utf8_lossy(&line);
|
let output = String::from_utf8_lossy(&line);
|
||||||
println!("App: {}", output);
|
println!("App: {}", output);
|
||||||
|
|
||||||
@@ -114,7 +146,7 @@ pub fn spawn_sidecar(app_handle: tauri::AppHandle) {
|
|||||||
|
|
||||||
// 创建主窗口
|
// 创建主窗口
|
||||||
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()),
|
||||||
@@ -123,11 +155,26 @@ pub fn spawn_sidecar(app_handle: tauri::AppHandle) {
|
|||||||
.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);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
// 超时检查
|
// 超时检查
|
||||||
if start_time.elapsed() > timeout {
|
if start_time.elapsed() > timeout {
|
||||||
|
|||||||
128
src/components/HealthRing.tsx
Normal file
128
src/components/HealthRing.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
291
src/components/TokenUsageDashboard.tsx
Normal file
291
src/components/TokenUsageDashboard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export * from './todo'
|
export * from './usage-history'
|
||||||
|
|||||||
@@ -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()),
|
|
||||||
})
|
|
||||||
27
src/db/schema/usage-history.ts
Normal file
27
src/db/schema/usage-history.ts
Normal 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())`),
|
||||||
|
})
|
||||||
75
src/env.ts
75
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 { 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
77
src/hooks/useCountdown.ts
Normal 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
63
src/hooks/useTheme.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() })
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as todo from './contracts/todo'
|
import * as usage from './contracts/usage'
|
||||||
|
|
||||||
export const contract = {
|
export const contract = {
|
||||||
todo,
|
usage,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
|
||||||
31
src/orpc/contracts/usage.ts
Normal file
31
src/orpc/contracts/usage.ts
Normal 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(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
@@ -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))
|
|
||||||
})
|
|
||||||
84
src/orpc/handlers/usage.ts
Normal file
84
src/orpc/handlers/usage.ts
Normal 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(),
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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 (在开发模式下)
|
||||||
|
* if (context.db) {
|
||||||
* return context.db.query.todoTable.findMany()
|
* return context.db.query.todoTable.findMany()
|
||||||
|
* }
|
||||||
* })
|
* })
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user