refactor: 优化代码结构,添加中文注释,完善 README 文档
- Hooks/组件添加 useMemo 优化,减少不必要的重计算 - 简化 TokenUsageDashboard 的 Suspense 嵌套层级 - 完善 README: 技术栈、构建产物位置、架构说明
This commit is contained in:
213
README.md
213
README.md
@@ -6,27 +6,50 @@
|
||||
|
||||
## 功能特点
|
||||
|
||||
- **实时配额监控**: 显示 4 个账户的 Claude Opus 4.5 (Thinking) 模型配额使用情况
|
||||
- **可视化仪表盘**: Apple Health 风格的圆环进度指示器
|
||||
- **实时配额监控**: 显示最多 4 个账户的 Claude Opus 4.5 (Thinking) 模型配额使用情况
|
||||
- **可视化仪表盘**: Apple Health 风格的圆环进度指示器,颜色根据剩余配额自动变化
|
||||
- **智能告警系统**:
|
||||
- 当配额剩余 < 20% 时显示警告 (Warning)
|
||||
- 当配额剩余 < 5% 时显示紧急告警 (Alarm)
|
||||
- **主题切换**: 支持白天/夜间模式切换
|
||||
- **主题切换**: 支持白天/夜间模式切换,OpenBridge 设计系统主题
|
||||
- **自动刷新**: 配额数据每 5 分钟自动刷新
|
||||
- **倒计时显示**: 显示配额重置剩余时间
|
||||
- **SSR 支持**: 服务端渲染,首屏即有数据
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层级 | 技术 |
|
||||
### 核心框架
|
||||
|
||||
| 层级 | 技术 | 版本 |
|
||||
|------|------|------|
|
||||
| 前端框架 | React | 19.2 |
|
||||
| 路由 | TanStack Router | 1.151 |
|
||||
| 状态管理 | TanStack Query | 5.90 |
|
||||
| SSR 框架 | TanStack Start | 1.151 |
|
||||
| RPC 通信 | ORPC (类型安全契约优先) | 1.13 |
|
||||
| UI 组件 | OpenBridge Web Components | 0.0.17 |
|
||||
| 样式 | Tailwind CSS | v4 |
|
||||
| 桌面壳 | Tauri | v2.9 |
|
||||
| 运行时 | Bun | 1.3.6 |
|
||||
|
||||
### 后端 & 数据
|
||||
|
||||
| 技术 | 说明 |
|
||||
|------|------|
|
||||
| 前端框架 | React 19 + TanStack Router |
|
||||
| UI 组件 | OpenBridge Web Components |
|
||||
| 状态管理 | TanStack Query |
|
||||
| 样式 | Tailwind CSS v4 |
|
||||
| RPC 通信 | ORPC (类型安全) |
|
||||
| 桌面壳 | Tauri v2 |
|
||||
| 运行时 | Bun |
|
||||
| 构建 | Vite + Turbo |
|
||||
| SQLite | Bun 内置驱动 (bun:sqlite),无需额外安装 |
|
||||
| Drizzle ORM | 类型安全 ORM,支持迁移 |
|
||||
| Nitro | 服务端框架 (bun preset) |
|
||||
| Zod | 运行时类型验证 |
|
||||
|
||||
### 构建工具
|
||||
|
||||
| 技术 | 说明 |
|
||||
|------|------|
|
||||
| Vite | 开发服务器 + 构建 |
|
||||
| Turbo | 任务并行化 |
|
||||
| Effect | 函数式构建脚本 |
|
||||
| Biome | 代码格式化 + Lint |
|
||||
| React Compiler | 自动优化,无需手动 memo |
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -53,13 +76,19 @@ bun run dev # Tauri 桌面应用 + Web
|
||||
### 构建
|
||||
|
||||
```bash
|
||||
bun run build:vite # 构建 Web 版本
|
||||
bun run build:vite # 构建 Web 版本 (输出到 .output/)
|
||||
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`
|
||||
### 构建产物位置
|
||||
|
||||
| 类型 | 路径 |
|
||||
|------|------|
|
||||
| **Vite Web 构建** | `.output/` |
|
||||
| **Sidecar 二进制** | `src-tauri/binaries/app-<target>` |
|
||||
| **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` |
|
||||
| **macOS DMG** | `src-tauri/target/release/bundle/dmg/app-desktop_0.1.0_aarch64.dmg` |
|
||||
|
||||
## 配置
|
||||
|
||||
@@ -92,15 +121,129 @@ TOKEN_USAGE_URL=http://your-server:8318/usage
|
||||
|
||||
## 常用命令
|
||||
|
||||
### 开发
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `bun dev` | 启动 Tauri + Vite 开发服务器 |
|
||||
| `bun dev:vite` | 仅启动 Vite 开发服务器 |
|
||||
| `bun dev` | 启动 Tauri + Vite 开发服务器 (并行) |
|
||||
| `bun dev:vite` | 仅启动 Vite 开发服务器 (http://localhost:3000) |
|
||||
| `bun dev:tauri` | 仅启动 Tauri (需先启动 Vite) |
|
||||
| `bun build` | 完整构建 (Tauri 桌面应用) |
|
||||
| `bun build:vite` | 仅构建 Web 版本 |
|
||||
| `bun db:studio` | 打开 Drizzle Studio 数据库管理界面 |
|
||||
|
||||
### 构建
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `bun build` | 完整构建 (Vite → 编译 → Tauri 打包) |
|
||||
| `bun build:vite` | 仅构建 Web 版本 (输出到 .output/) |
|
||||
| `bun build:compile` | 编译 Sidecar 二进制 (使用 build.ts) |
|
||||
| `bun build:tauri` | 构建 Tauri 桌面安装包 |
|
||||
|
||||
### 代码质量
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `bun typecheck` | TypeScript 类型检查 |
|
||||
| `bun fix` | 自动修复格式和 Lint 问题 |
|
||||
| `bun fix` | 自动修复格式和 Lint 问题 (Biome) |
|
||||
|
||||
### 数据库
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `bun db:init` | 初始化 SQLite 数据库 |
|
||||
| `bun db:generate` | 从 schema 生成迁移文件 |
|
||||
| `bun db:migrate` | 执行数据库迁移 |
|
||||
| `bun db:studio` | 打开 Drizzle Studio |
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
├── src/
|
||||
│ ├── components/ # React 组件
|
||||
│ │ ├── HealthRing.tsx # 圆环进度指示器 (Apple Health 风格)
|
||||
│ │ ├── TokenUsageDashboard.tsx # 主仪表盘 (告警+主题+圆环)
|
||||
│ │ ├── Error.tsx # 错误边界回退组件
|
||||
│ │ └── NotFount.tsx # 404 页面
|
||||
│ │
|
||||
│ ├── hooks/ # React Hooks
|
||||
│ │ ├── useCountdown.ts # 倒计时 Hook (useMemo 优化)
|
||||
│ │ └── useTheme.ts # OpenBridge 主题切换 Hook
|
||||
│ │
|
||||
│ ├── orpc/ # ORPC RPC 层
|
||||
│ │ ├── contracts/ # 契约定义 (Zod schema)
|
||||
│ │ │ └── usage.ts # 使用量查询契约
|
||||
│ │ ├── handlers/ # 服务端处理器
|
||||
│ │ │ └── usage.ts # 获取配额数据+存储历史
|
||||
│ │ ├── middlewares/ # ORPC 中间件
|
||||
│ │ │ └── db.ts # 数据库连接 Provider
|
||||
│ │ ├── client.ts # 同构客户端 (SSR/CSR 自动切换)
|
||||
│ │ ├── contract.ts # 契约聚合
|
||||
│ │ ├── router.ts # 路由组合
|
||||
│ │ ├── server.ts # 服务端实例
|
||||
│ │ └── types.ts # 类型导出
|
||||
│ │
|
||||
│ ├── routes/ # TanStack Router 文件路由
|
||||
│ │ ├── __root.tsx # 根布局 (OpenBridge 主题集成)
|
||||
│ │ ├── index.tsx # 首页 (SSR 预取+自动刷新)
|
||||
│ │ └── api/rpc.$.ts # ORPC HTTP 端点
|
||||
│ │
|
||||
│ ├── db/ # 数据库层
|
||||
│ │ ├── schema/ # Drizzle Schema
|
||||
│ │ │ └── usage-history.ts # 使用量历史表
|
||||
│ │ └── index.ts # 数据库连接 (SQLite WAL)
|
||||
│ │
|
||||
│ ├── lib/ # 工具函数
|
||||
│ ├── env.ts # 环境变量验证 (t3-env)
|
||||
│ └── router.tsx # 路由配置
|
||||
│
|
||||
├── src-tauri/ # Tauri 桌面应用
|
||||
│ ├── src/
|
||||
│ │ ├── lib.rs # Tauri 入口
|
||||
│ │ └── sidecar.rs # Sidecar 进程管理
|
||||
│ ├── binaries/ # 编译后的 sidecar 二进制
|
||||
│ └── tauri.conf.json # Tauri 配置
|
||||
│
|
||||
├── build.ts # 跨平台构建脚本 (Effect 框架)
|
||||
├── vite.config.ts # Vite 配置
|
||||
├── drizzle.config.ts # Drizzle ORM 配置
|
||||
└── .output/ # Vite 构建输出
|
||||
```
|
||||
|
||||
## 架构说明
|
||||
|
||||
### ORPC 同构客户端
|
||||
|
||||
ORPC 客户端根据运行环境自动选择最优调用方式:
|
||||
|
||||
- **SSR (服务端)**: 直接调用 router 处理器,零网络开销
|
||||
- **CSR (客户端)**: 通过 `/api/rpc` 端点进行 HTTP 调用
|
||||
|
||||
```tsx
|
||||
// 使用 TanStack Query
|
||||
const { data } = useSuspenseQuery(orpc.usage.getUsage.queryOptions())
|
||||
```
|
||||
|
||||
### Tauri Sidecar
|
||||
|
||||
应用采用 Sidecar 架构:
|
||||
|
||||
1. **Tauri 壳**: 提供原生窗口和系统集成
|
||||
2. **Bun 服务端**: 编译为独立可执行文件,处理 SSR 和 API 请求
|
||||
3. **通信**: Tauri WebView 通过 localhost:3000 与 Sidecar 通信
|
||||
|
||||
### 数据流
|
||||
|
||||
```
|
||||
Token API (http://10.0.1.1:8318/usage)
|
||||
↓
|
||||
ORPC Handler (usage.ts)
|
||||
↓
|
||||
SQLite (data/app.db) ← 历史记录存储
|
||||
↓
|
||||
TanStack Query Cache
|
||||
↓
|
||||
React Components (TokenUsageDashboard)
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
@@ -188,34 +331,6 @@ taskkill /F /PID <PID>
|
||||
2. 重新安装新的 MSI
|
||||
3. 确保安装目录有写入权限
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ │ ├── HealthRing.tsx # 圆环进度指示器
|
||||
│ │ ├── TokenUsageDashboard.tsx # 主仪表盘
|
||||
│ │ └── ...
|
||||
│ ├── hooks/
|
||||
│ │ └── useTheme.ts # 主题切换 Hook
|
||||
│ ├── orpc/
|
||||
│ │ ├── contracts/usage.ts # API 契约
|
||||
│ │ └── handlers/usage.ts # 数据获取逻辑
|
||||
│ ├── routes/
|
||||
│ │ ├── __root.tsx # 根布局
|
||||
│ │ └── index.tsx # 首页
|
||||
│ └── env.ts # 环境变量配置
|
||||
│
|
||||
├── src-tauri/ # Tauri 桌面应用
|
||||
│ ├── src/
|
||||
│ │ ├── lib.rs # Tauri 入口
|
||||
│ │ └── sidecar.rs # Sidecar 管理
|
||||
│ ├── binaries/ # 编译后的 sidecar
|
||||
│ └── tauri.conf.json # Tauri 配置
|
||||
│
|
||||
└── .output/ # Vite 构建输出
|
||||
```
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
|
||||
77
build.ts
77
build.ts
@@ -1,11 +1,29 @@
|
||||
/**
|
||||
* 跨平台构建脚本
|
||||
*
|
||||
* 使用 Effect 框架实现类型安全的多目标编译。
|
||||
* 将 TanStack Start 的 SSR 服务器打包为独立可执行文件,
|
||||
* 用于 Tauri 的 sidecar 机制。
|
||||
*
|
||||
* 支持目标平台:
|
||||
* - Windows (x64)
|
||||
* - macOS (ARM64/x64)
|
||||
* - Linux (x64/ARM64)
|
||||
*
|
||||
* 用法: bun run build:compile
|
||||
*/
|
||||
import { Schema } from '@effect/schema'
|
||||
import { $ } from 'bun'
|
||||
import { Console, Context, Data, Effect, Layer } from 'effect'
|
||||
|
||||
// ============================================================================
|
||||
// Domain Models & Schema
|
||||
// 领域模型和 Schema 定义
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Bun 目标平台到 Rust 目标三元组的映射
|
||||
* 用于生成 Tauri sidecar 所需的文件名格式
|
||||
*/
|
||||
const targetMap = {
|
||||
'bun-windows-x64': 'x86_64-pc-windows-msvc',
|
||||
'bun-darwin-arm64': 'aarch64-apple-darwin',
|
||||
@@ -14,6 +32,7 @@ const targetMap = {
|
||||
'bun-linux-arm64': 'aarch64-unknown-linux-gnu',
|
||||
} as const
|
||||
|
||||
/** Bun 编译目标 Schema */
|
||||
const BunTargetSchema = Schema.Literal(
|
||||
'bun-windows-x64',
|
||||
'bun-darwin-arm64',
|
||||
@@ -22,48 +41,63 @@ const BunTargetSchema = Schema.Literal(
|
||||
'bun-linux-arm64',
|
||||
)
|
||||
|
||||
/** Bun 编译目标类型 */
|
||||
type BunTarget = Schema.Schema.Type<typeof BunTargetSchema>
|
||||
|
||||
/** 构建配置 Schema */
|
||||
const BuildConfigSchema = Schema.Struct({
|
||||
/** 入口文件路径 */
|
||||
entrypoint: Schema.String.pipe(Schema.nonEmptyString()),
|
||||
/** 输出目录 */
|
||||
outputDir: Schema.String.pipe(Schema.nonEmptyString()),
|
||||
/** 目标平台列表 */
|
||||
targets: Schema.Array(BunTargetSchema).pipe(Schema.minItems(1)),
|
||||
})
|
||||
|
||||
/** 构建配置类型 */
|
||||
type BuildConfig = Schema.Schema.Type<typeof BuildConfigSchema>
|
||||
|
||||
/** 构建结果 Schema */
|
||||
const BuildResultSchema = Schema.Struct({
|
||||
/** 编译目标 */
|
||||
target: BunTargetSchema,
|
||||
/** 输出文件路径列表 */
|
||||
outputs: Schema.Array(Schema.String),
|
||||
})
|
||||
|
||||
/** 构建结果类型 */
|
||||
type BuildResult = Schema.Schema.Type<typeof BuildResultSchema>
|
||||
|
||||
// ============================================================================
|
||||
// Error Models (使用 Data.TaggedError)
|
||||
// 错误模型 (使用 Effect Data.TaggedError)
|
||||
// ============================================================================
|
||||
|
||||
/** 清理目录错误 */
|
||||
class CleanError extends Data.TaggedError('CleanError')<{
|
||||
readonly dir: string
|
||||
readonly cause: unknown
|
||||
}> {}
|
||||
|
||||
/** 构建错误 */
|
||||
class BuildError extends Data.TaggedError('BuildError')<{
|
||||
readonly target: BunTarget
|
||||
readonly cause: unknown
|
||||
}> {}
|
||||
|
||||
/** 配置验证错误 */
|
||||
class ConfigError extends Data.TaggedError('ConfigError')<{
|
||||
readonly message: string
|
||||
readonly cause: unknown
|
||||
}> {}
|
||||
|
||||
// ============================================================================
|
||||
// Services
|
||||
// 服务层
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 配置服务
|
||||
* 构建配置服务
|
||||
*
|
||||
* 提供类型安全的配置验证和默认配置。
|
||||
*/
|
||||
class BuildConfigService extends Context.Tag('BuildConfigService')<
|
||||
BuildConfigService,
|
||||
@@ -71,6 +105,9 @@ class BuildConfigService extends Context.Tag('BuildConfigService')<
|
||||
>() {
|
||||
/**
|
||||
* 从原始数据创建并验证配置
|
||||
*
|
||||
* @param raw - 原始配置对象
|
||||
* @returns 验证后的配置或配置错误
|
||||
*/
|
||||
static fromRaw = (raw: unknown) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -89,12 +126,13 @@ class BuildConfigService extends Context.Tag('BuildConfigService')<
|
||||
|
||||
/**
|
||||
* 默认配置 Layer
|
||||
*
|
||||
* 输出到 src-tauri/binaries 目录,供 Tauri sidecar 使用
|
||||
*/
|
||||
static readonly Live = Layer.effect(
|
||||
BuildConfigService,
|
||||
BuildConfigService.fromRaw({
|
||||
entrypoint: '.output/server/index.mjs',
|
||||
// outputDir: 'out',
|
||||
outputDir: 'src-tauri/binaries',
|
||||
targets: ['bun-windows-x64', 'bun-darwin-arm64', 'bun-linux-x64'],
|
||||
}),
|
||||
@@ -103,10 +141,13 @@ class BuildConfigService extends Context.Tag('BuildConfigService')<
|
||||
|
||||
/**
|
||||
* 文件系统服务
|
||||
*
|
||||
* 提供目录清理等文件操作。
|
||||
*/
|
||||
class FileSystemService extends Context.Tag('FileSystemService')<
|
||||
FileSystemService,
|
||||
{
|
||||
/** 清理指定目录 */
|
||||
readonly cleanDir: (dir: string) => Effect.Effect<void, CleanError>
|
||||
}
|
||||
>() {
|
||||
@@ -127,14 +168,18 @@ class FileSystemService extends Context.Tag('FileSystemService')<
|
||||
|
||||
/**
|
||||
* 构建服务
|
||||
*
|
||||
* 使用 Bun.build 进行跨平台编译。
|
||||
*/
|
||||
class BuildService extends Context.Tag('BuildService')<
|
||||
BuildService,
|
||||
{
|
||||
/** 为单个目标编译 */
|
||||
readonly buildForTarget: (
|
||||
config: BuildConfig,
|
||||
target: BunTarget,
|
||||
) => Effect.Effect<BuildResult, BuildError>
|
||||
/** 并行编译所有目标 */
|
||||
readonly buildAll: (
|
||||
config: BuildConfig,
|
||||
) => Effect.Effect<ReadonlyArray<BuildResult>, BuildError>
|
||||
@@ -172,6 +217,7 @@ class BuildService extends Context.Tag('BuildService')<
|
||||
|
||||
buildAll: (config: BuildConfig) =>
|
||||
Effect.gen(function* () {
|
||||
// 为每个目标创建编译任务
|
||||
const effects = config.targets.map((target) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Console.log(`🔨 开始构建: ${target}`)
|
||||
@@ -203,6 +249,7 @@ class BuildService extends Context.Tag('BuildService')<
|
||||
} satisfies BuildResult
|
||||
}),
|
||||
)
|
||||
// 并行执行所有编译任务
|
||||
return yield* Effect.all(effects, { concurrency: 'unbounded' })
|
||||
}),
|
||||
})
|
||||
@@ -210,10 +257,13 @@ class BuildService extends Context.Tag('BuildService')<
|
||||
|
||||
/**
|
||||
* 报告服务
|
||||
*
|
||||
* 输出构建结果摘要。
|
||||
*/
|
||||
class ReporterService extends Context.Tag('ReporterService')<
|
||||
ReporterService,
|
||||
{
|
||||
/** 打印构建摘要 */
|
||||
readonly printSummary: (
|
||||
results: ReadonlyArray<BuildResult>,
|
||||
) => Effect.Effect<void>
|
||||
@@ -234,9 +284,17 @@ class ReporterService extends Context.Tag('ReporterService')<
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Program
|
||||
// 主程序
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 构建流程主程序
|
||||
*
|
||||
* 步骤:
|
||||
* 1. 清理输出目录
|
||||
* 2. 并行构建所有目标平台
|
||||
* 3. 输出构建摘要
|
||||
*/
|
||||
const program = Effect.gen(function* () {
|
||||
const config = yield* BuildConfigService
|
||||
const fs = yield* FileSystemService
|
||||
@@ -257,9 +315,10 @@ const program = Effect.gen(function* () {
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Layer Composition
|
||||
// Layer 组合
|
||||
// ============================================================================
|
||||
|
||||
/** 合并所有服务 Layer */
|
||||
const MainLayer = Layer.mergeAll(
|
||||
BuildConfigService.Live,
|
||||
FileSystemService.Live,
|
||||
@@ -268,9 +327,10 @@ const MainLayer = Layer.mergeAll(
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Runner
|
||||
// 执行入口
|
||||
// ============================================================================
|
||||
|
||||
/** 可运行的程序(附带错误处理) */
|
||||
const runnable = program.pipe(
|
||||
Effect.provide(MainLayer),
|
||||
Effect.catchTags({
|
||||
@@ -284,6 +344,7 @@ const runnable = program.pipe(
|
||||
Effect.tapErrorCause((cause) => Console.error('❌ 未预期的错误:', cause)),
|
||||
)
|
||||
|
||||
// 执行程序
|
||||
Effect.runPromise(runnable).catch(() => {
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
30
bun.lock
30
bun.lock
@@ -5,6 +5,8 @@
|
||||
"": {
|
||||
"name": "fullstack-starter",
|
||||
"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",
|
||||
@@ -16,6 +18,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",
|
||||
@@ -129,6 +133,8 @@
|
||||
|
||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
||||
|
||||
"@effect/schema": ["@effect/schema@0.75.5", "", { "dependencies": { "fast-check": "^3.21.0" }, "peerDependencies": { "effect": "^3.9.2" } }, "sha512-TQInulTVCuF+9EIbJpyLP6dvxbQJMphrnRqgexm/Ze39rSjfhJuufF7XvU3SxTgg3HnL7B/kpORTJbHhlE6thw=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||
@@ -201,8 +207,22 @@
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@lit-labs/observers": ["@lit-labs/observers@2.1.0", "", { "dependencies": { "@lit/reactive-element": "^1.0.0 || ^2.0.0" } }, "sha512-oeQ2tvi/ygq7S+VgnXIInMKHHFPFqvIV9j2zBzpCwl33cWviT2ltuxNpbd11etwtUVsM8woKQzfVnrc80PKhKA=="],
|
||||
|
||||
"@lit-labs/ssr-dom-shim": ["@lit-labs/ssr-dom-shim@1.5.1", "", {}, "sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA=="],
|
||||
|
||||
"@lit/localize": ["@lit/localize@0.12.2", "", { "dependencies": { "lit": "^3.2.0" } }, "sha512-Qv9kvgJKDq/JVSwXOxuWvQnnOBysHA99ti9im9a4fImCmx+fto+XXcUYQbjZHqiueEEc4V20PcRDPO+1g/6seQ=="],
|
||||
|
||||
"@lit/react": ["@lit/react@1.0.8", "", { "peerDependencies": { "@types/react": "17 || 18 || 19" } }, "sha512-p2+YcF+JE67SRX3mMlJ1TKCSTsgyOVdAwd/nxp3NuV1+Cb6MWALbN6nT7Ld4tpmYofcE5kcaSY1YBB9erY+6fw=="],
|
||||
|
||||
"@lit/reactive-element": ["@lit/reactive-element@2.1.2", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.5.0" } }, "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||
|
||||
"@oicl/openbridge-webcomponents": ["@oicl/openbridge-webcomponents@0.0.17", "", { "dependencies": { "@lit-labs/observers": "^2.0.4", "@lit/localize": "^0.12.2", "lit": "^3.2.1" } }, "sha512-Rky530eF/C6BY43AW9Jl1WLa1CsCcH4YpExHdHo0cKF4xsquG4h5KE78mi0CO2ZRaN/MJjI46YrNu2jNYaqsag=="],
|
||||
|
||||
"@oicl/openbridge-webcomponents-react": ["@oicl/openbridge-webcomponents-react@0.0.17", "", { "dependencies": { "@lit/react": "^1.0.0", "@oicl/openbridge-webcomponents": "^0.0.17" }, "peerDependencies": { "@types/react": "^17 || ^18", "react": "^17 || ^18" } }, "sha512-WOa1QN4oEB3z6ROdD0Cfr5bJyPYHF3npAB8Z5x2nCiAQYAUyA1fCKcq0k9r7B/QY3QYXyO7hlHuz4lDJf6M7RA=="],
|
||||
|
||||
"@oozcitak/dom": ["@oozcitak/dom@2.0.2", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/url": "^3.0.0", "@oozcitak/util": "^10.0.0" } }, "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w=="],
|
||||
|
||||
"@oozcitak/infra": ["@oozcitak/infra@2.0.2", "", { "dependencies": { "@oozcitak/util": "^10.0.0" } }, "sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA=="],
|
||||
@@ -557,10 +577,12 @@
|
||||
|
||||
"@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.8", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg=="],
|
||||
"@types/react": ["@types/react@19.2.9", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
|
||||
"@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.2", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="],
|
||||
@@ -771,6 +793,12 @@
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
|
||||
|
||||
"lit": ["lit@3.3.2", "", { "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", "lit-html": "^3.3.0" } }, "sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ=="],
|
||||
|
||||
"lit-element": ["lit-element@4.2.2", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.5.0", "@lit/reactive-element": "^2.1.0", "lit-html": "^3.3.0" } }, "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w=="],
|
||||
|
||||
"lit-html": ["lit-html@3.3.2", "", { "dependencies": { "@types/trusted-types": "^2.0.2" } }, "sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw=="],
|
||||
|
||||
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
|
||||
|
||||
"lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="],
|
||||
|
||||
2
src-tauri/.gitignore
vendored
2
src-tauri/.gitignore
vendored
@@ -8,3 +8,5 @@
|
||||
|
||||
# Tauri Sidecar
|
||||
binaries/
|
||||
|
||||
.DS_Store
|
||||
@@ -1,3 +1,27 @@
|
||||
export function ErrorComponent() {
|
||||
return <div>An unhandled error happened!</div>
|
||||
/**
|
||||
* 错误边界回退组件
|
||||
*
|
||||
* 当应用发生未捕获的错误时显示此组件。
|
||||
* 提供友好的错误提示和刷新按钮。
|
||||
*/
|
||||
|
||||
/** 错误页面组件 */
|
||||
export const ErrorComponent = () => {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[var(--container-background-color)] text-[var(--on-container-color)]">
|
||||
<div className="text-center p-8">
|
||||
<h1 className="text-2xl font-bold mb-4">发生错误</h1>
|
||||
<p className="text-[var(--on-container-color)] opacity-70 mb-6">
|
||||
抱歉,应用遇到了未预期的问题。
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-[var(--primary-color)] text-white rounded hover:opacity-90 transition-opacity"
|
||||
>
|
||||
刷新页面
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* HealthRing 组件
|
||||
*
|
||||
* Apple 健康风格的圆环进度指示器
|
||||
* 中心显示倒计时
|
||||
* Apple 健康风格的圆环进度指示器,用于可视化配额使用情况。
|
||||
* 中心显示百分比和倒计时,颜色根据剩余配额自动变化。
|
||||
*/
|
||||
import { useMemo } from 'react'
|
||||
import { useCountdown } from '@/hooks/useCountdown'
|
||||
|
||||
/** 组件 Props 类型定义 */
|
||||
export interface HealthRingProps {
|
||||
/** 账户名称 */
|
||||
account: string
|
||||
@@ -17,28 +19,36 @@ export interface HealthRingProps {
|
||||
remainingFraction: number
|
||||
/** 配额重置时间 (ISO 8601) */
|
||||
resetTime?: string
|
||||
/** 圆环尺寸 */
|
||||
/** 圆环尺寸 (像素),默认 160 */
|
||||
size?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据剩余配额获取颜色
|
||||
* 颜色阈值配置
|
||||
* 根据剩余配额百分比决定显示颜色
|
||||
*/
|
||||
const getRingColor = (fraction: number): string => {
|
||||
if (fraction < 0.05) return '#FF3B30' // 红色 - 紧急
|
||||
if (fraction < 0.2) return '#FF9500' // 橙色 - 警告
|
||||
if (fraction < 0.5) return '#FFCC00' // 黄色 - 注意
|
||||
return '#34C759' // 绿色 - 正常
|
||||
}
|
||||
const COLOR_THRESHOLDS = [
|
||||
{ threshold: 0.05, color: '#FF3B30', bgColor: 'rgba(255, 59, 48, 0.2)' }, // 红色 - 紧急
|
||||
{ threshold: 0.2, color: '#FF9500', bgColor: 'rgba(255, 149, 0, 0.2)' }, // 橙色 - 警告
|
||||
{ threshold: 0.5, color: '#FFCC00', bgColor: 'rgba(255, 204, 0, 0.2)' }, // 黄色 - 注意
|
||||
] as const
|
||||
|
||||
/** 默认颜色 (绿色 - 正常状态) */
|
||||
const DEFAULT_COLOR = { color: '#34C759', bgColor: 'rgba(52, 199, 89, 0.2)' }
|
||||
|
||||
/**
|
||||
* 根据剩余配额获取背景色(较暗)
|
||||
* 根据剩余配额获取对应的颜色配置
|
||||
*
|
||||
* @param fraction - 剩余配额百分比 (0-1)
|
||||
* @returns 前景色和背景色配置
|
||||
*/
|
||||
const getRingBgColor = (fraction: number): string => {
|
||||
if (fraction < 0.05) return 'rgba(255, 59, 48, 0.2)'
|
||||
if (fraction < 0.2) return 'rgba(255, 149, 0, 0.2)'
|
||||
if (fraction < 0.5) return 'rgba(255, 204, 0, 0.2)'
|
||||
return 'rgba(52, 199, 89, 0.2)'
|
||||
const getColorConfig = (
|
||||
fraction: number,
|
||||
): { color: string; bgColor: string } => {
|
||||
for (const { threshold, color, bgColor } of COLOR_THRESHOLDS) {
|
||||
if (fraction < threshold) return { color, bgColor }
|
||||
}
|
||||
return DEFAULT_COLOR
|
||||
}
|
||||
|
||||
export const HealthRing = ({
|
||||
@@ -50,20 +60,25 @@ export const HealthRing = ({
|
||||
size = 160,
|
||||
}: HealthRingProps) => {
|
||||
const countdown = useCountdown(resetTime)
|
||||
const percentage = Math.round(remainingFraction * 100)
|
||||
|
||||
// SVG 参数
|
||||
// 使用 useMemo 缓存 SVG 计算值,避免不必要的重新计算
|
||||
const svgParams = useMemo(() => {
|
||||
const strokeWidth = size * 0.1
|
||||
const radius = (size - strokeWidth) / 2
|
||||
const circumference = 2 * Math.PI * radius
|
||||
const strokeDashoffset = circumference * (1 - remainingFraction)
|
||||
|
||||
const ringColor = getRingColor(remainingFraction)
|
||||
const ringBgColor = getRingBgColor(remainingFraction)
|
||||
return { strokeWidth, radius, circumference, strokeDashoffset }
|
||||
}, [size, remainingFraction])
|
||||
|
||||
const percentage = Math.round(remainingFraction * 100)
|
||||
const { color: ringColor, bgColor: ringBgColor } =
|
||||
getColorConfig(remainingFraction)
|
||||
const { strokeWidth, radius, circumference, strokeDashoffset } = svgParams
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{/* 圆环 */}
|
||||
{/* 圆环容器 */}
|
||||
<div className="relative" style={{ width: size, height: size }}>
|
||||
<svg
|
||||
width={size}
|
||||
@@ -97,7 +112,7 @@ export const HealthRing = ({
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* 中心内容 - 倒计时 */}
|
||||
{/* 中心内容 - 百分比和倒计时 */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-2xl font-bold" style={{ color: ringColor }}>
|
||||
{percentage}%
|
||||
@@ -108,7 +123,7 @@ export const HealthRing = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 标签 */}
|
||||
{/* 底部标签 */}
|
||||
<div className="text-center">
|
||||
<div
|
||||
className="text-sm font-medium truncate max-w-[140px] text-[var(--on-container-color)]"
|
||||
|
||||
@@ -1,3 +1,29 @@
|
||||
export function NotFoundComponent() {
|
||||
return <div>404 - Not Found</div>
|
||||
/**
|
||||
* 404 页面未找到组件
|
||||
*
|
||||
* 当用户访问不存在的路由时显示此组件。
|
||||
* 提供友好的提示和返回首页按钮。
|
||||
*/
|
||||
|
||||
/** 404 页面组件 */
|
||||
export const NotFoundComponent = () => {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[var(--container-background-color)] text-[var(--on-container-color)]">
|
||||
<div className="text-center p-8">
|
||||
<h1 className="text-6xl font-bold mb-4 text-[var(--on-container-color)] opacity-30">
|
||||
404
|
||||
</h1>
|
||||
<h2 className="text-xl font-medium mb-4">页面未找到</h2>
|
||||
<p className="text-[var(--on-container-color)] opacity-70 mb-6">
|
||||
抱歉,您访问的页面不存在。
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
className="inline-block px-4 py-2 bg-[var(--primary-color)] text-white rounded hover:opacity-90 transition-opacity"
|
||||
>
|
||||
返回首页
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
/**
|
||||
* TokenUsageDashboard 组件
|
||||
*
|
||||
* 主仪表盘,展示 4 个账户的 claude-opus-4-5-thinking 配额使用情况
|
||||
* 使用 OpenBridge TopBar + AlertTopbarElement
|
||||
* 主仪表盘,展示多个账户的 claude-opus-4-5-thinking 配额使用情况。
|
||||
* 使用 OpenBridge 设计系统的 TopBar 和 Alert 组件。
|
||||
*
|
||||
* 特性:
|
||||
* - 多账户配额可视化 (最多显示 4 个)
|
||||
* - 实时告警通知 (低于 20% 警告,低于 5% 紧急)
|
||||
* - 支持日间/夜间主题切换
|
||||
* - OpenBridge 组件懒加载以避免 SSR 问题
|
||||
*/
|
||||
import '@oicl/openbridge-webcomponents/dist/icons/icon-palette-day.js'
|
||||
import '@oicl/openbridge-webcomponents/dist/icons/icon-palette-night.js'
|
||||
import { AlertType } from '@oicl/openbridge-webcomponents/dist/types'
|
||||
import { lazy, Suspense, useCallback, useMemo, useState } from 'react'
|
||||
import { HealthRing } from '@/components/HealthRing'
|
||||
import { useTheme } from '@/hooks/useTheme'
|
||||
import { type ObcTheme, useTheme } from '@/hooks/useTheme'
|
||||
import type { ModelUsage } from '@/orpc/contracts/usage'
|
||||
|
||||
// 懒加载 OpenBridge 组件以避免 SSR 问题
|
||||
// ============================================================================
|
||||
// 懒加载 OpenBridge 组件(避免 SSR 问题)
|
||||
// ============================================================================
|
||||
|
||||
const ObcTopBar = lazy(() =>
|
||||
import(
|
||||
'@oicl/openbridge-webcomponents-react/components/top-bar/top-bar'
|
||||
@@ -49,17 +58,19 @@ const ObcNavigationItem = lazy(() =>
|
||||
).then((mod) => ({ default: mod.ObcNavigationItem })),
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
// ============================================================================
|
||||
|
||||
export interface TokenUsageDashboardProps {
|
||||
/** 从 API 获取的使用量数据 */
|
||||
data: {
|
||||
opusModels: ModelUsage[]
|
||||
fetchedAt: string
|
||||
}
|
||||
}
|
||||
|
||||
/** 告警阈值 */
|
||||
const ALERT_THRESHOLD = 0.2 // 20% 警戒线
|
||||
const CRITICAL_THRESHOLD = 0.05 // 5% 紧急阈值
|
||||
|
||||
/** 告警信息类型 */
|
||||
interface AlertInfo {
|
||||
account: string
|
||||
model: string
|
||||
@@ -68,18 +79,37 @@ interface AlertInfo {
|
||||
type: AlertType
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 常量配置
|
||||
// ============================================================================
|
||||
|
||||
/** 告警阈值配置 */
|
||||
const ALERT_THRESHOLD = 0.2 // 20% - 警告阈值
|
||||
const CRITICAL_THRESHOLD = 0.05 // 5% - 紧急阈值
|
||||
|
||||
/** 最大显示的账户数 */
|
||||
const MAX_DISPLAY_ACCOUNTS = 4
|
||||
|
||||
/** 已知的账户前缀列表 */
|
||||
const KNOWN_PREFIXES = [
|
||||
'antigravity-',
|
||||
'anthropic-',
|
||||
'claude-',
|
||||
'openai-',
|
||||
] as const
|
||||
|
||||
// ============================================================================
|
||||
// 工具函数
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 从账户名中提取用户名部分
|
||||
* 例如: "antigravity-2220328339_qq" -> "2220328339_qq"
|
||||
*
|
||||
* @param account - 完整账户名 (如 "antigravity-2220328339_qq")
|
||||
* @returns 提取后的用户名 (如 "2220328339_qq")
|
||||
*/
|
||||
const extractUsername = (account: string): string => {
|
||||
// 移除 "antigravity-" 前缀
|
||||
if (account.startsWith('antigravity-')) {
|
||||
return account.slice('antigravity-'.length)
|
||||
}
|
||||
// 移除其他常见前缀
|
||||
const prefixes = ['anthropic-', 'claude-', 'openai-']
|
||||
for (const prefix of prefixes) {
|
||||
for (const prefix of KNOWN_PREFIXES) {
|
||||
if (account.startsWith(prefix)) {
|
||||
return account.slice(prefix.length)
|
||||
}
|
||||
@@ -89,11 +119,17 @@ const extractUsername = (account: string): string => {
|
||||
|
||||
/**
|
||||
* 计算告警列表
|
||||
*
|
||||
* 根据配额剩余比例生成告警列表,并按严重程度排序。
|
||||
*
|
||||
* @param models - 模型使用量列表
|
||||
* @returns 排序后的告警列表 (Alarm 优先)
|
||||
*/
|
||||
const getAlerts = (models: ModelUsage[]): AlertInfo[] => {
|
||||
const computeAlerts = (models: ModelUsage[]): AlertInfo[] => {
|
||||
const alerts: AlertInfo[] = []
|
||||
|
||||
for (const model of models) {
|
||||
// 低于 5% 为紧急告警
|
||||
if (model.remainingFraction < CRITICAL_THRESHOLD) {
|
||||
alerts.push({
|
||||
account: model.account,
|
||||
@@ -102,7 +138,9 @@ const getAlerts = (models: ModelUsage[]): AlertInfo[] => {
|
||||
remainingFraction: model.remainingFraction,
|
||||
type: AlertType.Alarm,
|
||||
})
|
||||
} else if (model.remainingFraction < ALERT_THRESHOLD) {
|
||||
}
|
||||
// 低于 20% 为警告
|
||||
else if (model.remainingFraction < ALERT_THRESHOLD) {
|
||||
alerts.push({
|
||||
account: model.account,
|
||||
model: model.model,
|
||||
@@ -113,16 +151,20 @@ const getAlerts = (models: ModelUsage[]): AlertInfo[] => {
|
||||
}
|
||||
}
|
||||
|
||||
// 按严重程度排序(Alarm 优先)
|
||||
// 按严重程度排序: Alarm > Warning,相同级别按剩余配额升序
|
||||
return alerts.sort((a, b) => {
|
||||
if (a.type === AlertType.Alarm && b.type !== AlertType.Alarm) return -1
|
||||
if (a.type !== AlertType.Alarm && b.type === AlertType.Alarm) return 1
|
||||
if (a.type !== b.type) {
|
||||
return a.type === AlertType.Alarm ? -1 : 1
|
||||
}
|
||||
return a.remainingFraction - b.remainingFraction
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最高告警类型
|
||||
* 获取最高级别的告警类型
|
||||
*
|
||||
* @param alerts - 告警列表
|
||||
* @returns 最高级别的告警类型
|
||||
*/
|
||||
const getHighestAlertType = (alerts: AlertInfo[]): AlertType => {
|
||||
if (alerts.some((a) => a.type === AlertType.Alarm)) return AlertType.Alarm
|
||||
@@ -130,42 +172,62 @@ const getHighestAlertType = (alerts: AlertInfo[]): AlertType => {
|
||||
return AlertType.Caution
|
||||
}
|
||||
|
||||
/**
|
||||
* TopBar 占位符(SSR 时显示)
|
||||
*/
|
||||
// ============================================================================
|
||||
// 子组件
|
||||
// ============================================================================
|
||||
|
||||
/** TopBar 加载占位符 (SSR 时显示) */
|
||||
const TopBarFallback = () => (
|
||||
<div className="h-14 bg-[var(--container-background-color)] border-b border-[var(--divider-color)]" />
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// 主组件
|
||||
// ============================================================================
|
||||
|
||||
export const TokenUsageDashboard = ({ data }: TokenUsageDashboardProps) => {
|
||||
const { opusModels } = data
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
// UI 状态
|
||||
const [alertMuted, setAlertMuted] = useState(false)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
// 计算告警
|
||||
const alerts = useMemo(() => getAlerts(opusModels), [opusModels])
|
||||
const alertType = getHighestAlertType(alerts)
|
||||
// 计算告警信息 (使用 useMemo 缓存)
|
||||
const alerts = useMemo(() => computeAlerts(opusModels), [opusModels])
|
||||
const alertType = useMemo(() => getHighestAlertType(alerts), [alerts])
|
||||
|
||||
// 处理静音点击
|
||||
// 获取最重要的告警 (用于顶栏显示)
|
||||
const topAlert = alerts[0]
|
||||
|
||||
// ========== 事件处理器 ==========
|
||||
|
||||
/** 切换告警静音状态 */
|
||||
const handleMuteClick = useCallback(() => {
|
||||
setAlertMuted((prev) => !prev)
|
||||
}, [])
|
||||
|
||||
// 处理菜单按钮点击
|
||||
/** 切换菜单开关状态 */
|
||||
const handleMenuButtonClick = useCallback(() => {
|
||||
setMenuOpen((prev) => !prev)
|
||||
}, [])
|
||||
|
||||
// 处理主题切换
|
||||
/** 关闭侧边菜单 */
|
||||
const closeMenu = useCallback(() => {
|
||||
setMenuOpen(false)
|
||||
}, [])
|
||||
|
||||
/** 切换主题并关闭菜单 */
|
||||
const handleThemeChange = useCallback(
|
||||
(newTheme: 'day' | 'night') => {
|
||||
(newTheme: ObcTheme) => {
|
||||
setTheme(newTheme)
|
||||
setMenuOpen(false)
|
||||
},
|
||||
[setTheme],
|
||||
)
|
||||
|
||||
// ========== 渲染 ==========
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-[var(--container-background-color)] text-[var(--on-container-color)]">
|
||||
{/* 顶部导航栏 */}
|
||||
@@ -179,9 +241,8 @@ export const TokenUsageDashboard = ({ data }: TokenUsageDashboardProps) => {
|
||||
}
|
||||
menuButtonActivated={menuOpen}
|
||||
>
|
||||
{/* 右侧: 告警面板 */}
|
||||
{/* 右侧告警面板 */}
|
||||
<div slot="alerts">
|
||||
<Suspense fallback={null}>
|
||||
<ObcAlertTopbarElement
|
||||
nAlerts={alerts.length}
|
||||
alertType={alertType}
|
||||
@@ -189,29 +250,24 @@ export const TokenUsageDashboard = ({ data }: TokenUsageDashboardProps) => {
|
||||
showAck={false}
|
||||
onMuteclick={handleMuteClick as unknown as EventListener}
|
||||
>
|
||||
{alerts.length > 0 && alerts[0] && (
|
||||
<Suspense fallback={null}>
|
||||
{topAlert && (
|
||||
<ObcNotificationMessageItem time="">
|
||||
<span slot="icon">
|
||||
<Suspense fallback={null}>
|
||||
<ObcAlertIcon
|
||||
name={
|
||||
alerts[0].type === AlertType.Alarm
|
||||
topAlert.type === AlertType.Alarm
|
||||
? 'alarm-unack'
|
||||
: 'warning-unack'
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
</span>
|
||||
<span slot="message">
|
||||
{extractUsername(alerts[0].account)}: 剩余{' '}
|
||||
{Math.round(alerts[0].remainingFraction * 100)}%
|
||||
{extractUsername(topAlert.account)}: 剩余{' '}
|
||||
{Math.round(topAlert.remainingFraction * 100)}%
|
||||
</span>
|
||||
</ObcNotificationMessageItem>
|
||||
</Suspense>
|
||||
)}
|
||||
</ObcAlertTopbarElement>
|
||||
</Suspense>
|
||||
</div>
|
||||
</ObcTopBar>
|
||||
</Suspense>
|
||||
@@ -223,7 +279,7 @@ export const TokenUsageDashboard = ({ data }: TokenUsageDashboardProps) => {
|
||||
<Suspense fallback={null}>
|
||||
<ObcNavigationMenu>
|
||||
<div slot="main">
|
||||
<Suspense fallback={null}>
|
||||
{/* 白天模式选项 */}
|
||||
<ObcNavigationItem
|
||||
label="白天模式"
|
||||
checked={theme === 'day'}
|
||||
@@ -237,8 +293,8 @@ export const TokenUsageDashboard = ({ data }: TokenUsageDashboardProps) => {
|
||||
}}
|
||||
/>
|
||||
</ObcNavigationItem>
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
|
||||
{/* 夜间模式选项 */}
|
||||
<ObcNavigationItem
|
||||
label="夜间模式"
|
||||
checked={theme === 'night'}
|
||||
@@ -252,7 +308,6 @@ export const TokenUsageDashboard = ({ data }: TokenUsageDashboardProps) => {
|
||||
}}
|
||||
/>
|
||||
</ObcNavigationItem>
|
||||
</Suspense>
|
||||
</div>
|
||||
</ObcNavigationMenu>
|
||||
</Suspense>
|
||||
@@ -265,16 +320,15 @@ export const TokenUsageDashboard = ({ data }: TokenUsageDashboardProps) => {
|
||||
type="button"
|
||||
aria-label="关闭菜单"
|
||||
className="fixed inset-0 z-30 bg-black/20 cursor-default"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
onKeyDown={(e) => e.key === 'Escape' && setMenuOpen(false)}
|
||||
onClick={closeMenu}
|
||||
onKeyDown={(e) => e.key === 'Escape' && closeMenu()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 主内容区 */}
|
||||
{/* 主内容区 - 配额圆环展示 */}
|
||||
<main className="flex-1 flex flex-col items-center justify-center p-8">
|
||||
{/* 4 个圆环横向排列 */}
|
||||
<div className="flex flex-wrap justify-center gap-10 lg:gap-16">
|
||||
{opusModels.slice(0, 4).map((model) => (
|
||||
{opusModels.slice(0, MAX_DISPLAY_ACCOUNTS).map((model) => (
|
||||
<HealthRing
|
||||
key={`${model.account}-${model.model}`}
|
||||
account={extractUsername(model.account)}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
/**
|
||||
* 数据库连接模块
|
||||
*
|
||||
* 使用 Bun 内置的 SQLite 驱动,无需额外安装原生模块。
|
||||
* 数据库文件存储在可执行文件同级的 data/app.db
|
||||
* 使用 Bun 内置的 SQLite 驱动 (bun:sqlite),无需安装额外的原生模块。
|
||||
* 数据库文件存储在可执行文件同目录的 data/app.db。
|
||||
*
|
||||
* 特性:
|
||||
* - WAL 模式提升并发性能
|
||||
* - 自动创建表结构
|
||||
* - 智能路径检测 (开发/生产)
|
||||
*/
|
||||
|
||||
import { Database } from 'bun:sqlite'
|
||||
@@ -11,16 +16,31 @@ import { dirname, join } from 'node:path'
|
||||
import { drizzle } from 'drizzle-orm/bun-sqlite'
|
||||
import * as schema from '@/db/schema'
|
||||
|
||||
/**
|
||||
* 获取数据库路径
|
||||
* - 在打包后的 sidecar 中,使用可执行文件所在目录
|
||||
* - 在开发模式下,使用项目根目录
|
||||
*/
|
||||
function getDbPath(): string {
|
||||
const execPath = process.execPath
|
||||
const isBundled = !execPath.includes('node') && !execPath.includes('bun')
|
||||
// ============================================================================
|
||||
// 路径工具函数
|
||||
// ============================================================================
|
||||
|
||||
const baseDir = isBundled ? dirname(execPath) : process.cwd()
|
||||
/**
|
||||
* 判断是否为打包后的可执行文件运行环境
|
||||
*
|
||||
* @returns 是否为打包后的二进制文件
|
||||
*/
|
||||
const isBundledExec = (): boolean => {
|
||||
const execPath = process.execPath
|
||||
return !execPath.includes('node') && !execPath.includes('bun')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据库文件存储路径
|
||||
*
|
||||
* 路径策略:
|
||||
* - 打包后的 sidecar: 使用可执行文件所在目录
|
||||
* - 开发模式: 使用项目根目录 (process.cwd())
|
||||
*
|
||||
* @returns 数据库文件完整路径
|
||||
*/
|
||||
const getDbPath = (): string => {
|
||||
const baseDir = isBundledExec() ? dirname(process.execPath) : process.cwd()
|
||||
const dataDir = join(baseDir, 'data')
|
||||
|
||||
// 确保 data 目录存在
|
||||
@@ -31,14 +51,24 @@ function getDbPath(): string {
|
||||
return join(dataDir, 'app.db')
|
||||
}
|
||||
|
||||
/** 数据库文件路径 */
|
||||
// ============================================================================
|
||||
// 表初始化
|
||||
// ============================================================================
|
||||
|
||||
/** 数据库文件路径 (在模块加载时计算一次) */
|
||||
const DB_PATH = getDbPath()
|
||||
|
||||
/**
|
||||
* 初始化数据库表结构
|
||||
*
|
||||
* 使用 IF NOT EXISTS 确保幂等性,可安全多次执行。
|
||||
* 创建 usage_history 表和相应的索引。
|
||||
*
|
||||
* @param sqlite - SQLite 数据库实例
|
||||
*/
|
||||
function initTables(sqlite: Database) {
|
||||
const initTables = (sqlite: Database): void => {
|
||||
sqlite.exec(`
|
||||
-- 使用量历史记录表
|
||||
CREATE TABLE IF NOT EXISTS usage_history (
|
||||
id TEXT PRIMARY KEY,
|
||||
account TEXT NOT NULL,
|
||||
@@ -48,26 +78,38 @@ function initTables(sqlite: Database) {
|
||||
reset_time TEXT,
|
||||
recorded_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
-- 按记录时间查询的索引
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_history_recorded_at
|
||||
ON usage_history(recorded_at);
|
||||
`)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 数据库连接
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 创建数据库连接
|
||||
*
|
||||
* 启用 WAL (Write-Ahead Logging) 模式以提高并发读写性能。
|
||||
* 如果数据库文件不存在,会自动创建并初始化表结构。
|
||||
* 特性:
|
||||
* - 启用 WAL (Write-Ahead Logging) 模式提高并发读写性能
|
||||
* - 自动创建数据库文件 (如不存在)
|
||||
* - 自动初始化表结构
|
||||
*
|
||||
* @returns Drizzle ORM 数据库实例
|
||||
*/
|
||||
export function createDb() {
|
||||
export const createDb = () => {
|
||||
const sqlite = new Database(DB_PATH, { create: true })
|
||||
|
||||
// 启用 WAL 模式,提升并发性能
|
||||
sqlite.exec('PRAGMA journal_mode = WAL;')
|
||||
|
||||
// 自动初始化表结构
|
||||
// 初始化表结构
|
||||
initTables(sqlite)
|
||||
|
||||
return drizzle(sqlite, { schema })
|
||||
}
|
||||
|
||||
/** 数据库实例类型 */
|
||||
/** 数据库实例类型 (用于 TypeScript 类型推导) */
|
||||
export type Db = ReturnType<typeof createDb>
|
||||
|
||||
148
src/env.ts
148
src/env.ts
@@ -1,11 +1,72 @@
|
||||
/**
|
||||
* 环境变量配置模块
|
||||
*
|
||||
* 使用 @t3-oss/env-core 进行类型安全的环境变量验证。
|
||||
* 支持从同目录的 .env 文件加载配置(优先级低于系统环境变量)。
|
||||
*/
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { createEnv } from '@t3-oss/env-core'
|
||||
import { z } from 'zod'
|
||||
|
||||
/** 默认的 TOKEN_USAGE_URL */
|
||||
/** Token 使用量 API 的默认地址 */
|
||||
const DEFAULT_TOKEN_USAGE_URL = 'http://10.0.1.1:8318/usage'
|
||||
|
||||
/**
|
||||
* 判断当前是否为打包后的可执行文件运行环境
|
||||
*
|
||||
* @returns 是否为打包后的二进制文件
|
||||
*/
|
||||
const isBundledExec = (): boolean => {
|
||||
const execPath = process.execPath
|
||||
return !execPath.includes('node') && !execPath.includes('bun')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置文件的基础目录
|
||||
*
|
||||
* - 打包后的 sidecar: 使用可执行文件所在目录
|
||||
* - 开发模式: 使用项目根目录
|
||||
*
|
||||
* @returns 基础目录路径
|
||||
*/
|
||||
const getBaseDir = (): string =>
|
||||
isBundledExec() ? dirname(process.execPath) : process.cwd()
|
||||
|
||||
/**
|
||||
* 解析 .env 文件内容
|
||||
*
|
||||
* 支持:
|
||||
* - 空行和 # 开头的注释
|
||||
* - KEY=value 格式
|
||||
* - 只设置系统环境变量中不存在的变量
|
||||
*
|
||||
* @param content - .env 文件内容
|
||||
* @returns 解析后的环境变量对象
|
||||
*/
|
||||
const parseEnvContent = (content: string): Record<string, string> => {
|
||||
const result: Record<string, string> = {}
|
||||
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
// 跳过空行和注释
|
||||
if (!trimmed || trimmed.startsWith('#')) continue
|
||||
|
||||
const eqIndex = trimmed.indexOf('=')
|
||||
if (eqIndex <= 0) continue
|
||||
|
||||
const key = trimmed.slice(0, eqIndex).trim()
|
||||
const value = trimmed.slice(eqIndex + 1).trim()
|
||||
|
||||
// 只设置系统环境变量中不存在的变量
|
||||
if (!process.env[key]) {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 从同目录的 .env 配置文件读取环境变量
|
||||
*
|
||||
@@ -13,63 +74,54 @@ const DEFAULT_TOKEN_USAGE_URL = 'http://10.0.1.1:8318/usage'
|
||||
* 1. 系统环境变量 (process.env)
|
||||
* 2. 可执行文件同目录的 .env 文件
|
||||
* 3. 默认值
|
||||
*
|
||||
* @returns 从文件解析的环境变量
|
||||
*/
|
||||
function loadEnvFromFile(): Record<string, string> {
|
||||
const result: Record<string, string> = {}
|
||||
const loadEnvFromFile = (): Record<string, string> => {
|
||||
const envPath = join(getBaseDir(), '.env')
|
||||
|
||||
// 确定可执行文件所在目录
|
||||
const execPath = process.execPath
|
||||
const isBundled = !execPath.includes('node') && !execPath.includes('bun')
|
||||
const baseDir = isBundled ? dirname(execPath) : process.cwd()
|
||||
const envPath = join(baseDir, '.env')
|
||||
if (!existsSync(envPath)) return {}
|
||||
|
||||
// 如果 .env 文件存在,解析它
|
||||
if (existsSync(envPath)) {
|
||||
try {
|
||||
const content = readFileSync(envPath, 'utf-8')
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
// 跳过空行和注释
|
||||
if (!trimmed || trimmed.startsWith('#')) continue
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
return parseEnvContent(content)
|
||||
} 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
|
||||
// 忽略读取错误(权限问题等)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果仍然没有 TOKEN_USAGE_URL,使用默认值
|
||||
if (!mergedEnv.TOKEN_USAGE_URL) {
|
||||
mergedEnv.TOKEN_USAGE_URL = DEFAULT_TOKEN_USAGE_URL
|
||||
/**
|
||||
* 构建合并后的环境变量对象
|
||||
*
|
||||
* 合并顺序: process.env > fileEnv > 默认值
|
||||
*/
|
||||
const buildMergedEnv = (): Record<string, string | undefined> => {
|
||||
const fileEnv = loadEnvFromFile()
|
||||
const merged: Record<string, string | undefined> = { ...process.env }
|
||||
|
||||
// 从文件填充缺失的变量
|
||||
for (const [key, value] of Object.entries(fileEnv)) {
|
||||
if (!merged[key]) {
|
||||
merged[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
// 设置默认值
|
||||
merged.TOKEN_USAGE_URL ??= DEFAULT_TOKEN_USAGE_URL
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* 类型安全的环境变量配置
|
||||
*
|
||||
* 服务端变量:
|
||||
* - TOKEN_USAGE_URL: Token 使用量 API 地址
|
||||
*
|
||||
* 客户端变量 (VITE_ 前缀):
|
||||
* - VITE_APP_TITLE: 应用标题 (可选)
|
||||
*/
|
||||
export const env = createEnv({
|
||||
server: {
|
||||
TOKEN_USAGE_URL: z.string().url(),
|
||||
@@ -78,6 +130,6 @@ export const env = createEnv({
|
||||
client: {
|
||||
VITE_APP_TITLE: z.string().min(1).optional(),
|
||||
},
|
||||
runtimeEnv: mergedEnv,
|
||||
runtimeEnv: buildMergedEnv(),
|
||||
emptyStringAsUndefined: true,
|
||||
})
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* 倒计时 Hook
|
||||
*
|
||||
* 计算目标时间到当前时间的剩余时间,每秒更新一次
|
||||
* 计算目标时间到当前时间的剩余时间,每秒更新一次。
|
||||
* 使用 useMemo 优化计算,避免不必要的重渲染。
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
/** 倒计时结果类型 */
|
||||
export interface CountdownResult {
|
||||
/** 剩余总秒数 */
|
||||
totalSeconds: number
|
||||
@@ -20,58 +22,84 @@ export interface CountdownResult {
|
||||
isExpired: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算并实时更新目标时间的倒计时
|
||||
* @param targetTime ISO 8601 格式的目标时间字符串
|
||||
* @returns 倒计时结果
|
||||
*/
|
||||
export const useCountdown = (
|
||||
targetTime: string | undefined,
|
||||
): CountdownResult => {
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
/** 时间常量 (毫秒) */
|
||||
const SECOND = 1000
|
||||
const MINUTE = 60
|
||||
const HOUR = 3600
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setNow(Date.now())
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
if (!targetTime) {
|
||||
return {
|
||||
/** 无效/过期时的默认返回值 */
|
||||
const EXPIRED_RESULT: CountdownResult = {
|
||||
totalSeconds: 0,
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
formatted: '--:--',
|
||||
isExpired: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化倒计时为可读字符串
|
||||
*
|
||||
* @param hours - 小时数
|
||||
* @param minutes - 分钟数
|
||||
* @param seconds - 秒数
|
||||
* @returns 格式化后的字符串 (如 "2h 15m" 或 "45m 30s")
|
||||
*/
|
||||
const formatCountdown = (
|
||||
hours: number,
|
||||
minutes: number,
|
||||
seconds: number,
|
||||
): string => {
|
||||
if (hours > 0) return `${hours}h ${minutes}m`
|
||||
if (minutes > 0) return `${minutes}m ${seconds}s`
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算并实时更新目标时间的倒计时
|
||||
*
|
||||
* @param targetTime - ISO 8601 格式的目标时间字符串
|
||||
* @returns 倒计时结果对象
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const countdown = useCountdown('2024-12-31T23:59:59Z')
|
||||
* console.log(countdown.formatted) // "2h 15m"
|
||||
* ```
|
||||
*/
|
||||
export const useCountdown = (
|
||||
targetTime: string | undefined,
|
||||
): CountdownResult => {
|
||||
const [now, setNow] = useState(Date.now)
|
||||
|
||||
// 每秒更新当前时间
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNow(Date.now()), SECOND)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
// 使用 useMemo 缓存计算结果,仅在 now 或 targetTime 变化时重新计算
|
||||
return useMemo(() => {
|
||||
if (!targetTime) return EXPIRED_RESULT
|
||||
|
||||
const target = new Date(targetTime).getTime()
|
||||
const diff = Math.max(0, target - now)
|
||||
const totalSeconds = Math.floor(diff / 1000)
|
||||
const totalSeconds = Math.floor(diff / SECOND)
|
||||
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
// 已过期
|
||||
if (totalSeconds === 0) return EXPIRED_RESULT
|
||||
|
||||
let formatted: string
|
||||
if (hours > 0) {
|
||||
formatted = `${hours}h ${minutes}m`
|
||||
} else if (minutes > 0) {
|
||||
formatted = `${minutes}m ${seconds}s`
|
||||
} else {
|
||||
formatted = `${seconds}s`
|
||||
}
|
||||
const hours = Math.floor(totalSeconds / HOUR)
|
||||
const minutes = Math.floor((totalSeconds % HOUR) / MINUTE)
|
||||
const seconds = totalSeconds % MINUTE
|
||||
|
||||
return {
|
||||
totalSeconds,
|
||||
hours,
|
||||
minutes,
|
||||
seconds,
|
||||
formatted,
|
||||
isExpired: totalSeconds === 0,
|
||||
formatted: formatCountdown(hours, minutes, seconds),
|
||||
isExpired: false,
|
||||
}
|
||||
}, [now, targetTime])
|
||||
}
|
||||
|
||||
@@ -1,42 +1,70 @@
|
||||
/**
|
||||
* OpenBridge 主题管理 Hook
|
||||
*
|
||||
* 管理 data-obc-theme 属性,支持 day/dusk/night/bright 四种主题
|
||||
* 主题选择会持久化到 localStorage
|
||||
* 管理 data-obc-theme 属性,支持 day/dusk/night/bright 四种主题。
|
||||
* 主题选择会持久化到 localStorage。
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
/** 支持的 OpenBridge 主题类型 */
|
||||
export type ObcTheme = 'day' | 'dusk' | 'night' | 'bright'
|
||||
|
||||
/** 主题列表,用于循环切换 */
|
||||
const THEMES: readonly ObcTheme[] = ['day', 'dusk', 'night', 'bright'] as const
|
||||
|
||||
/** localStorage 存储键名 */
|
||||
const STORAGE_KEY = 'obc-theme'
|
||||
|
||||
/** 默认主题 */
|
||||
const DEFAULT_THEME: ObcTheme = 'day'
|
||||
|
||||
/**
|
||||
* 类型守卫:检查值是否为有效的主题
|
||||
*
|
||||
* @param value - 待检查的值
|
||||
* @returns 是否为有效主题
|
||||
*/
|
||||
const isValidTheme = (value: unknown): value is ObcTheme =>
|
||||
typeof value === 'string' && THEMES.includes(value as ObcTheme)
|
||||
|
||||
/**
|
||||
* 获取初始主题(从 localStorage 或默认值)
|
||||
*
|
||||
* @returns 初始主题值
|
||||
*/
|
||||
const getInitialTheme = (): ObcTheme => {
|
||||
if (typeof window === 'undefined') {
|
||||
return DEFAULT_THEME
|
||||
}
|
||||
// SSR 环境下使用默认主题
|
||||
if (typeof window === 'undefined') return DEFAULT_THEME
|
||||
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored && ['day', 'dusk', 'night', 'bright'].includes(stored)) {
|
||||
return stored as ObcTheme
|
||||
}
|
||||
return DEFAULT_THEME
|
||||
return isValidTheme(stored) ? stored : DEFAULT_THEME
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理 OpenBridge 主题切换
|
||||
*
|
||||
* @returns 主题状态和控制函数
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { theme, setTheme, cycleTheme } = useTheme()
|
||||
*
|
||||
* // 设置为夜间模式
|
||||
* setTheme('night')
|
||||
*
|
||||
* // 循环切换到下一个主题
|
||||
* cycleTheme()
|
||||
* ```
|
||||
*/
|
||||
export const useTheme = () => {
|
||||
const [theme, setThemeState] = useState<ObcTheme>(getInitialTheme)
|
||||
|
||||
// 应用主题到 DOM
|
||||
// 应用主题到 DOM 并持久化
|
||||
useEffect(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
if (typeof document === 'undefined') return
|
||||
|
||||
document.documentElement.setAttribute('data-obc-theme', theme)
|
||||
localStorage.setItem(STORAGE_KEY, theme)
|
||||
}
|
||||
}, [theme])
|
||||
|
||||
// 切换到指定主题
|
||||
@@ -46,18 +74,12 @@ export const useTheme = () => {
|
||||
|
||||
// 循环切换主题
|
||||
const cycleTheme = useCallback(() => {
|
||||
const themes: ObcTheme[] = ['day', 'dusk', 'night', 'bright']
|
||||
const currentIndex = themes.indexOf(theme)
|
||||
const nextIndex = (currentIndex + 1) % themes.length
|
||||
const nextTheme = themes[nextIndex]
|
||||
if (nextTheme) {
|
||||
setThemeState(nextTheme)
|
||||
}
|
||||
}, [theme])
|
||||
setThemeState((current) => {
|
||||
const currentIndex = THEMES.indexOf(current)
|
||||
const nextIndex = (currentIndex + 1) % THEMES.length
|
||||
return THEMES[nextIndex] ?? DEFAULT_THEME
|
||||
})
|
||||
}, [])
|
||||
|
||||
return {
|
||||
theme,
|
||||
setTheme,
|
||||
cycleTheme,
|
||||
}
|
||||
return { theme, setTheme, cycleTheme } as const
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* ORPC 同构客户端
|
||||
*
|
||||
* 根据运行环境自动选择最优调用方式:
|
||||
* - SSR (服务端): 直接调用 router,无 HTTP 开销
|
||||
* - CSR (客户端): 通过 /api/rpc 端点 HTTP 调用
|
||||
* 根据运行环境自动选择最优的 RPC 调用方式:
|
||||
* - SSR (服务端): 直接调用 router 处理器,零网络开销
|
||||
* - CSR (客户端): 通过 /api/rpc 端点进行 HTTP 调用
|
||||
*
|
||||
* 同时配置了 TanStack Query 集成,mutation 成功后自动刷新相关查询。
|
||||
* 同时集成 TanStack Query,提供开箱即用的查询/突变 hooks。
|
||||
*/
|
||||
import { createORPCClient } from '@orpc/client'
|
||||
import { RPCLink } from '@orpc/client/fetch'
|
||||
@@ -16,36 +16,58 @@ import { getRequestHeaders } from '@tanstack/react-start/server'
|
||||
import { router } from './router'
|
||||
import type { RouterClient } from './types'
|
||||
|
||||
// ============================================================================
|
||||
// 客户端创建
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 创建同构 ORPC 客户端
|
||||
*
|
||||
* 服务端: 直接调用路由处理器
|
||||
* 客户端: 通过 HTTP 调用 /api/rpc 端点
|
||||
* 使用 TanStack Start 的 createIsomorphicFn 实现服务端/客户端代码分离:
|
||||
* - server(): 在 SSR 时执行,直接调用路由处理器
|
||||
* - client(): 在浏览器中执行,通过 HTTP 调用 API
|
||||
*/
|
||||
const getORPCClient = createIsomorphicFn()
|
||||
.server(() =>
|
||||
// 服务端: 创建直接调用路由器的客户端
|
||||
createRouterClient(router, {
|
||||
// 传递原始请求的 headers (用于身份验证等)
|
||||
context: () => ({
|
||||
headers: getRequestHeaders(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.client(() => {
|
||||
// 客户端: 创建 HTTP 客户端
|
||||
const link = new RPCLink({
|
||||
url: `${window.location.origin}/api/rpc`,
|
||||
})
|
||||
return createORPCClient<RouterClient>(link)
|
||||
})
|
||||
|
||||
/** 同构客户端实例 */
|
||||
const client: RouterClient = getORPCClient()
|
||||
|
||||
// ============================================================================
|
||||
// TanStack Query 集成
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* ORPC + TanStack Query 工具
|
||||
* ORPC + TanStack Query 工具集
|
||||
*
|
||||
* 使用方式:
|
||||
* 提供类型安全的 queryOptions 和 mutationOptions 方法。
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // 查询
|
||||
* // 查询 (使用 Suspense)
|
||||
* const { data } = useSuspenseQuery(orpc.usage.getUsage.queryOptions())
|
||||
*
|
||||
* // 查询 (不使用 Suspense)
|
||||
* const { data, isLoading } = useQuery(orpc.usage.getUsage.queryOptions())
|
||||
*
|
||||
* // 突变
|
||||
* const mutation = useMutation(orpc.todo.create.mutationOptions())
|
||||
* mutation.mutate({ title: '新任务' })
|
||||
* ```
|
||||
*/
|
||||
export const orpc = createTanstackQueryUtils(client)
|
||||
|
||||
@@ -1,26 +1,44 @@
|
||||
/**
|
||||
* 路由配置模块
|
||||
*
|
||||
* 创建并配置 TanStack Router 实例,集成 TanStack Query 进行 SSR 数据获取。
|
||||
*
|
||||
* 特性:
|
||||
* - 自动滚动恢复
|
||||
* - SSR 查询集成
|
||||
* - 文件路由(由 routeTree.gen.ts 生成)
|
||||
*/
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { createRouter } from '@tanstack/react-router'
|
||||
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
|
||||
import type { RouterContext } from './routes/__root'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
|
||||
/**
|
||||
* 创建路由实例工厂函数
|
||||
*
|
||||
* 每次调用创建新的 QueryClient 和 Router 实例。
|
||||
* 这对于 SSR 很重要,避免请求之间共享状态。
|
||||
*
|
||||
* @returns 配置好的 TanStack Router 实例
|
||||
*/
|
||||
export const getRouter = () => {
|
||||
// 创建 TanStack Query 客户端
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
// 创建路由实例
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
context: {
|
||||
queryClient,
|
||||
} satisfies RouterContext,
|
||||
|
||||
context: { queryClient } satisfies RouterContext,
|
||||
// 启用滚动位置恢复
|
||||
scrollRestoration: true,
|
||||
// 预加载数据立即过期,确保总是获取最新数据
|
||||
defaultPreloadStaleTime: 0,
|
||||
})
|
||||
|
||||
setupRouterSsrQueryIntegration({
|
||||
router,
|
||||
queryClient,
|
||||
})
|
||||
// 设置 SSR 查询集成
|
||||
// 将路由器的预取与 TanStack Query 的缓存连接起来
|
||||
setupRouterSsrQueryIntegration({ router, queryClient })
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
/**
|
||||
* 根布局组件
|
||||
*
|
||||
* 定义应用的 HTML 结构、全局样式和错误处理。
|
||||
*
|
||||
* 特性:
|
||||
* - OpenBridge 设计系统集成 (CSS 变量主题)
|
||||
* - TanStack Router 上下文配置
|
||||
* - 全局错误边界和 404 处理
|
||||
*/
|
||||
import '@oicl/openbridge-webcomponents/src/palettes/variables.css'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
@@ -10,36 +20,44 @@ import { ErrorComponent } from '@/components/Error'
|
||||
import { NotFoundComponent } from '@/components/NotFount'
|
||||
import appCss from '@/styles.css?url'
|
||||
|
||||
/** 路由上下文类型 - 包含 TanStack Query 客户端 */
|
||||
export interface RouterContext {
|
||||
queryClient: QueryClient
|
||||
}
|
||||
|
||||
/**
|
||||
* 根路由定义
|
||||
*
|
||||
* 配置:
|
||||
* - head: 页面 meta 标签和样式表
|
||||
* - shellComponent: HTML 文档结构
|
||||
* - errorComponent: 错误边界回退
|
||||
* - notFoundComponent: 404 页面
|
||||
*/
|
||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{
|
||||
charSet: 'utf-8',
|
||||
},
|
||||
{
|
||||
name: 'viewport',
|
||||
content: 'width=device-width, initial-scale=1',
|
||||
},
|
||||
{
|
||||
title: 'Token Usage Viewer',
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{
|
||||
rel: 'stylesheet',
|
||||
href: appCss,
|
||||
},
|
||||
{ charSet: 'utf-8' },
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||
{ title: 'Token Usage Viewer' },
|
||||
],
|
||||
links: [{ rel: 'stylesheet', href: appCss }],
|
||||
}),
|
||||
shellComponent: RootDocument,
|
||||
errorComponent: () => <ErrorComponent />,
|
||||
notFoundComponent: () => <NotFoundComponent />,
|
||||
})
|
||||
|
||||
/**
|
||||
* HTML 文档根结构
|
||||
*
|
||||
* 设置:
|
||||
* - lang="zh-Hans": 简体中文
|
||||
* - data-obc-theme="day": OpenBridge 默认日间主题
|
||||
* - obc-component-size-regular: OpenBridge 标准组件尺寸
|
||||
*
|
||||
* @param children - 页面内容
|
||||
*/
|
||||
function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
|
||||
return (
|
||||
<html lang="zh-Hans" data-obc-theme="day">
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
/**
|
||||
* Token Usage Viewer 主页面
|
||||
*
|
||||
* 展示 Opus/Thinking 模型的配额使用情况仪表盘
|
||||
* 展示 claude-opus-4-5-thinking 模型的配额使用情况仪表盘。
|
||||
*
|
||||
* 特性:
|
||||
* - SSR 预加载数据(通过 loader)
|
||||
* - 每 5 分钟自动刷新配额数据
|
||||
* - Tauri 环境下自动设置窗口标题
|
||||
*/
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
@@ -11,8 +16,16 @@ import { useEffect } from 'react'
|
||||
import { TokenUsageDashboard } from '@/components/TokenUsageDashboard'
|
||||
import { orpc } from '@/orpc'
|
||||
|
||||
/** 数据自动刷新间隔 (毫秒) - 5 分钟 */
|
||||
const REFETCH_INTERVAL = 5 * 60 * 1000
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
component: Home,
|
||||
/**
|
||||
* 路由加载器 - SSR 数据预取
|
||||
*
|
||||
* 在服务端渲染时预先获取使用量数据,确保首次渲染即有内容。
|
||||
*/
|
||||
loader: async ({ context }) => {
|
||||
await context.queryClient.ensureQueryData(
|
||||
orpc.usage.getUsage.queryOptions(),
|
||||
@@ -20,13 +33,19 @@ export const Route = createFileRoute('/')({
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* 首页组件
|
||||
*
|
||||
* 使用 useSuspenseQuery 获取并展示配额数据,
|
||||
* 确保 data 不为空(由 loader 预取保证)。
|
||||
*/
|
||||
function Home() {
|
||||
const { data } = useSuspenseQuery({
|
||||
...orpc.usage.getUsage.queryOptions(),
|
||||
refetchInterval: 300000, // 每 5 分钟自动刷新
|
||||
refetchInterval: REFETCH_INTERVAL,
|
||||
})
|
||||
|
||||
// 设置 Tauri 窗口标题
|
||||
// Tauri 环境: 设置窗口标题
|
||||
useEffect(() => {
|
||||
if (!isTauri()) return
|
||||
getCurrentWindow().setTitle('Token Usage Viewer')
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
/**
|
||||
* Vite 构建配置
|
||||
*
|
||||
* 集成 TanStack Start (SSR) + Nitro (服务端) + Tailwind CSS + React Compiler。
|
||||
*
|
||||
* 特性:
|
||||
* - Bun 运行时优化 (nitro preset: 'bun')
|
||||
* - 静态资源内联 (serveStatic: 'inline')
|
||||
* - React Compiler 自动优化 (无需手动 memo)
|
||||
* - 开发时热更新 (端口 3000)
|
||||
*/
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { devtools as tanstackDevtools } from '@tanstack/devtools-vite'
|
||||
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
|
||||
@@ -6,30 +17,52 @@ import { nitro } from 'nitro/vite'
|
||||
import { defineConfig } from 'vite'
|
||||
import tsconfigPaths from 'vite-tsconfig-paths'
|
||||
|
||||
/** 开发服务器端口 */
|
||||
const DEV_PORT = 3000
|
||||
|
||||
export default defineConfig({
|
||||
// 禁止清屏,方便与 Tauri 开发工具共用终端
|
||||
clearScreen: false,
|
||||
|
||||
build: {
|
||||
cssMinify: 'esbuild', // 使用 esbuild 替代 lightningcss 避免第三方 CSS 兼容性问题
|
||||
// 使用 esbuild 进行 CSS 压缩
|
||||
// 避免 lightningcss 处理第三方 CSS 时的兼容性问题
|
||||
cssMinify: 'esbuild',
|
||||
},
|
||||
|
||||
plugins: [
|
||||
// TanStack 开发工具
|
||||
tanstackDevtools(),
|
||||
|
||||
// Nitro 服务端框架 (Bun 运行时)
|
||||
nitro({
|
||||
preset: 'bun',
|
||||
serveStatic: 'inline',
|
||||
}),
|
||||
|
||||
// TypeScript 路径别名 (@/* -> src/*)
|
||||
tsconfigPaths(),
|
||||
|
||||
// Tailwind CSS v4
|
||||
tailwindcss(),
|
||||
|
||||
// TanStack Start SSR 框架
|
||||
tanstackStart(),
|
||||
|
||||
// React + Babel (启用 React Compiler)
|
||||
react({
|
||||
babel: {
|
||||
plugins: ['babel-plugin-react-compiler'],
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
server: {
|
||||
port: 3000,
|
||||
port: DEV_PORT,
|
||||
// 如果端口被占用则报错,而不是自动切换端口
|
||||
strictPort: true,
|
||||
watch: {
|
||||
// 忽略 Tauri 源码目录,避免不必要的重编译
|
||||
ignored: ['**/src-tauri/**'],
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user