From 13a873ec76abbbb37e08da40c8d8afdf30c84d56 Mon Sep 17 00:00:00 2001 From: MAO Dongyang Date: Wed, 21 Jan 2026 20:15:34 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E4=BC=98=E5=8C=96=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E7=BB=93=E6=9E=84=EF=BC=8C=E6=B7=BB=E5=8A=A0=E4=B8=AD?= =?UTF-8?q?=E6=96=87=E6=B3=A8=E9=87=8A=EF=BC=8C=E5=AE=8C=E5=96=84=20README?= =?UTF-8?q?=20=E6=96=87=E6=A1=A3=20-=20Hooks/=E7=BB=84=E4=BB=B6=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=20useMemo=20=E4=BC=98=E5=8C=96=EF=BC=8C=E5=87=8F?= =?UTF-8?q?=E5=B0=91=E4=B8=8D=E5=BF=85=E8=A6=81=E7=9A=84=E9=87=8D=E8=AE=A1?= =?UTF-8?q?=E7=AE=97=20-=20=E7=AE=80=E5=8C=96=20TokenUsageDashboard=20?= =?UTF-8?q?=E7=9A=84=20Suspense=20=E5=B5=8C=E5=A5=97=E5=B1=82=E7=BA=A7=20-?= =?UTF-8?q?=20=E5=AE=8C=E5=96=84=20README:=20=E6=8A=80=E6=9C=AF=E6=A0=88?= =?UTF-8?q?=E3=80=81=E6=9E=84=E5=BB=BA=E4=BA=A7=E7=89=A9=E4=BD=8D=E7=BD=AE?= =?UTF-8?q?=E3=80=81=E6=9E=B6=E6=9E=84=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 213 ++++++++++++++++----- build.ts | 77 +++++++- bun.lock | 30 ++- src-tauri/.gitignore | 2 + src/components/Error.tsx | 28 ++- src/components/HealthRing.tsx | 71 ++++--- src/components/NotFount.tsx | 30 ++- src/components/TokenUsageDashboard.tsx | 252 +++++++++++++++---------- src/db/index.ts | 78 ++++++-- src/env.ts | 152 ++++++++++----- src/hooks/useCountdown.ts | 114 ++++++----- src/hooks/useTheme.ts | 76 +++++--- src/orpc/client.ts | 40 +++- src/router.tsx | 34 +++- src/routes/__root.tsx | 50 +++-- src/routes/index.tsx | 25 ++- vite.config.ts | 37 +++- 17 files changed, 944 insertions(+), 365 deletions(-) diff --git a/README.md b/README.md index fa3ec12..bd4fd69 100644 --- a/README.md +++ b/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-` | +| **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 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 diff --git a/build.ts b/build.ts index dace663..e0c1887 100644 --- a/build.ts +++ b/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 +/** 构建配置 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 +/** 构建结果 Schema */ const BuildResultSchema = Schema.Struct({ + /** 编译目标 */ target: BunTargetSchema, + /** 输出文件路径列表 */ outputs: Schema.Array(Schema.String), }) +/** 构建结果类型 */ type BuildResult = Schema.Schema.Type // ============================================================================ -// 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 } >() { @@ -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 + /** 并行编译所有目标 */ readonly buildAll: ( config: BuildConfig, ) => Effect.Effect, 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, ) => Effect.Effect @@ -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) }) diff --git a/bun.lock b/bun.lock index 7100680..765b013 100644 --- a/bun.lock +++ b/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=="], diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore index a2c0bd7..6b6508f 100644 --- a/src-tauri/.gitignore +++ b/src-tauri/.gitignore @@ -8,3 +8,5 @@ # Tauri Sidecar binaries/ + +.DS_Store \ No newline at end of file diff --git a/src/components/Error.tsx b/src/components/Error.tsx index f25b194..02801fe 100644 --- a/src/components/Error.tsx +++ b/src/components/Error.tsx @@ -1,3 +1,27 @@ -export function ErrorComponent() { - return
An unhandled error happened!
+/** + * 错误边界回退组件 + * + * 当应用发生未捕获的错误时显示此组件。 + * 提供友好的错误提示和刷新按钮。 + */ + +/** 错误页面组件 */ +export const ErrorComponent = () => { + return ( +
+
+

发生错误

+

+ 抱歉,应用遇到了未预期的问题。 +

+ +
+
+ ) } diff --git a/src/components/HealthRing.tsx b/src/components/HealthRing.tsx index 2aa3efd..119a791 100644 --- a/src/components/HealthRing.tsx +++ b/src/components/HealthRing.tsx @@ -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) + + // 使用 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) + + return { strokeWidth, radius, circumference, strokeDashoffset } + }, [size, remainingFraction]) + 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) + const { color: ringColor, bgColor: ringBgColor } = + getColorConfig(remainingFraction) + const { strokeWidth, radius, circumference, strokeDashoffset } = svgParams return (
- {/* 圆环 */} + {/* 圆环容器 */}
- {/* 中心内容 - 倒计时 */} + {/* 中心内容 - 百分比和倒计时 */}
{percentage}% @@ -108,7 +123,7 @@ export const HealthRing = ({
- {/* 标签 */} + {/* 底部标签 */}
404 - Not Found
+/** + * 404 页面未找到组件 + * + * 当用户访问不存在的路由时显示此组件。 + * 提供友好的提示和返回首页按钮。 + */ + +/** 404 页面组件 */ +export const NotFoundComponent = () => { + return ( +
+
+

+ 404 +

+

页面未找到

+

+ 抱歉,您访问的页面不存在。 +

+ + 返回首页 + +
+
+ ) } diff --git a/src/components/TokenUsageDashboard.tsx b/src/components/TokenUsageDashboard.tsx index 002643c..1c45e2f 100644 --- a/src/components/TokenUsageDashboard.tsx +++ b/src/components/TokenUsageDashboard.tsx @@ -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 = () => (
) +// ============================================================================ +// 主组件 +// ============================================================================ + 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 (
{/* 顶部导航栏 */} @@ -179,39 +241,33 @@ export const TokenUsageDashboard = ({ data }: TokenUsageDashboardProps) => { } menuButtonActivated={menuOpen} > - {/* 右侧: 告警面板 */} + {/* 右侧告警面板 */}
- - - {alerts.length > 0 && alerts[0] && ( - - - - - - - - - {extractUsername(alerts[0].account)}: 剩余{' '} - {Math.round(alerts[0].remainingFraction * 100)}% - - - - )} - - + + {topAlert && ( + + + + + + {extractUsername(topAlert.account)}: 剩余{' '} + {Math.round(topAlert.remainingFraction * 100)}% + + + )} +
@@ -223,36 +279,35 @@ export const TokenUsageDashboard = ({ data }: TokenUsageDashboardProps) => {
- - handleThemeChange('day')} - > - ', - }} - /> - - - - handleThemeChange('night')} - > - ', - }} - /> - - + {/* 白天模式选项 */} + handleThemeChange('day')} + > + ', + }} + /> + + + {/* 夜间模式选项 */} + handleThemeChange('night')} + > + ', + }} + /> +
@@ -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()} /> )} - {/* 主内容区 */} + {/* 主内容区 - 配额圆环展示 */}
- {/* 4 个圆环横向排列 */}
- {opusModels.slice(0, 4).map((model) => ( + {opusModels.slice(0, MAX_DISPLAY_ACCOUNTS).map((model) => ( { + 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 diff --git a/src/env.ts b/src/env.ts index 36e939c..ba4581d 100644 --- a/src/env.ts +++ b/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 => { + const result: Record = {} + + 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 { - const result: Record = {} +const loadEnvFromFile = (): Record => { + 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 + try { + const content = readFileSync(envPath, 'utf-8') + return parseEnvContent(content) + } catch { + // 忽略读取错误(权限问题等) + return {} + } +} - 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 { - // 忽略读取错误 +/** + * 构建合并后的环境变量对象 + * + * 合并顺序: process.env > fileEnv > 默认值 + */ +const buildMergedEnv = (): Record => { + const fileEnv = loadEnvFromFile() + const merged: Record = { ...process.env } + + // 从文件填充缺失的变量 + for (const [key, value] of Object.entries(fileEnv)) { + if (!merged[key]) { + merged[key] = value } } - return result -} - -// 加载配置文件中的环境变量 -const fileEnv = loadEnvFromFile() - -// 合并环境变量: process.env > fileEnv > 默认值 -const mergedEnv: Record = { - ...process.env, -} - -// 从文件填充缺失的变量 -for (const [key, value] of Object.entries(fileEnv)) { - if (!mergedEnv[key]) { - mergedEnv[key] = value - } -} - -// 如果仍然没有 TOKEN_USAGE_URL,使用默认值 -if (!mergedEnv.TOKEN_USAGE_URL) { - mergedEnv.TOKEN_USAGE_URL = DEFAULT_TOKEN_USAGE_URL + // 设置默认值 + 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, }) diff --git a/src/hooks/useCountdown.ts b/src/hooks/useCountdown.ts index 31a1e1d..4cd5cf0 100644 --- a/src/hooks/useCountdown.ts +++ b/src/hooks/useCountdown.ts @@ -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 } +/** 时间常量 (毫秒) */ +const SECOND = 1000 +const MINUTE = 60 +const HOUR = 3600 + +/** 无效/过期时的默认返回值 */ +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 倒计时结果 + * + * @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()) + const [now, setNow] = useState(Date.now) + // 每秒更新当前时间 useEffect(() => { - const timer = setInterval(() => { - setNow(Date.now()) - }, 1000) - + const timer = setInterval(() => setNow(Date.now()), SECOND) return () => clearInterval(timer) }, []) - if (!targetTime) { + // 使用 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 / SECOND) + + // 已过期 + if (totalSeconds === 0) return EXPIRED_RESULT + + const hours = Math.floor(totalSeconds / HOUR) + const minutes = Math.floor((totalSeconds % HOUR) / MINUTE) + const seconds = totalSeconds % MINUTE + return { - totalSeconds: 0, - hours: 0, - minutes: 0, - seconds: 0, - formatted: '--:--', - isExpired: true, + totalSeconds, + hours, + minutes, + seconds, + formatted: formatCountdown(hours, minutes, seconds), + isExpired: false, } - } - - 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, - } + }, [now, targetTime]) } diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts index 37acef7..ddae111 100644 --- a/src/hooks/useTheme.ts +++ b/src/hooks/useTheme.ts @@ -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(getInitialTheme) - // 应用主题到 DOM + // 应用主题到 DOM 并持久化 useEffect(() => { - if (typeof document !== 'undefined') { - document.documentElement.setAttribute('data-obc-theme', theme) - localStorage.setItem(STORAGE_KEY, theme) - } + 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 } diff --git a/src/orpc/client.ts b/src/orpc/client.ts index 750174d..b0b491c 100644 --- a/src/orpc/client.ts +++ b/src/orpc/client.ts @@ -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(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) diff --git a/src/router.tsx b/src/router.tsx index 2625061..c348000 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -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 } diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 826d520..bd23e2d 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -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()({ 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: () => , notFoundComponent: () => , }) +/** + * HTML 文档根结构 + * + * 设置: + * - lang="zh-Hans": 简体中文 + * - data-obc-theme="day": OpenBridge 默认日间主题 + * - obc-component-size-regular: OpenBridge 标准组件尺寸 + * + * @param children - 页面内容 + */ function RootDocument({ children }: Readonly<{ children: ReactNode }>) { return ( diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 99802db..698203c 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -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') diff --git a/vite.config.ts b/vite.config.ts index 42a0178..cb9f898 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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/**'], }, },