Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f2db4bff9d | |||
| d22a0f8d69 | |||
| fa625ca301 | |||
| b520cdaf35 | |||
| d077dfdd90 | |||
| 13a873ec76 |
@@ -1 +1,3 @@
|
|||||||
TOKEN_USAGE_URL=
|
TOKEN_USAGE_URL=http://10.0.1.1:8318/api/usage/model
|
||||||
|
PROJECT_SERVER_PORT=13098
|
||||||
|
# 不要更改
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
### 开发
|
### 开发
|
||||||
```bash
|
```bash
|
||||||
bun dev # 使用 Turbo 并行启动 Tauri + Vite 开发服务器
|
bun dev # 使用 Turbo 并行启动 Tauri + Vite 开发服务器
|
||||||
bun dev:vite # 仅启动 Vite 开发服务器 (localhost:3000)
|
bun dev:vite # 仅启动 Vite 开发服务器 (localhost:13098)
|
||||||
bun dev:tauri # 启动 Tauri 桌面应用
|
bun dev:tauri # 启动 Tauri 桌面应用
|
||||||
bun db:studio # 打开 Drizzle Studio 数据库管理界面
|
bun db:studio # 打开 Drizzle Studio 数据库管理界面
|
||||||
```
|
```
|
||||||
|
|||||||
223
README.md
223
README.md
@@ -6,27 +6,50 @@
|
|||||||
|
|
||||||
## 功能特点
|
## 功能特点
|
||||||
|
|
||||||
- **实时配额监控**: 显示 4 个账户的 Claude Opus 4.5 (Thinking) 模型配额使用情况
|
- **实时配额监控**: 显示最多 4 个账户的 Claude Opus 4.5 (Thinking) 模型配额使用情况
|
||||||
- **可视化仪表盘**: Apple Health 风格的圆环进度指示器
|
- **可视化仪表盘**: Apple Health 风格的圆环进度指示器,颜色根据剩余配额自动变化
|
||||||
- **智能告警系统**:
|
- **智能告警系统**:
|
||||||
- 当配额剩余 < 20% 时显示警告 (Warning)
|
- 当配额剩余 < 20% 时显示警告 (Warning)
|
||||||
- 当配额剩余 < 5% 时显示紧急告警 (Alarm)
|
- 当配额剩余 < 5% 时显示紧急告警 (Alarm)
|
||||||
- **主题切换**: 支持白天/夜间模式切换
|
- **主题切换**: 支持白天/夜间模式切换,OpenBridge 设计系统主题
|
||||||
- **自动刷新**: 配额数据每 5 分钟自动刷新
|
- **自动刷新**: 配额数据每 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 |
|
| SQLite | Bun 内置驱动 (bun:sqlite),无需额外安装 |
|
||||||
| UI 组件 | OpenBridge Web Components |
|
| Drizzle ORM | 类型安全 ORM,支持迁移 |
|
||||||
| 状态管理 | TanStack Query |
|
| Nitro | 服务端框架 (bun preset) |
|
||||||
| 样式 | Tailwind CSS v4 |
|
| Zod | 运行时类型验证 |
|
||||||
| RPC 通信 | ORPC (类型安全) |
|
|
||||||
| 桌面壳 | Tauri v2 |
|
### 构建工具
|
||||||
| 运行时 | Bun |
|
|
||||||
| 构建 | Vite + Turbo |
|
| 技术 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| Vite | 开发服务器 + 构建 |
|
||||||
|
| Turbo | 任务并行化 |
|
||||||
|
| Effect | 函数式构建脚本 |
|
||||||
|
| Biome | 代码格式化 + Lint |
|
||||||
|
| React Compiler | 自动优化,无需手动 memo |
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
@@ -46,20 +69,26 @@ cd openbridge-token-usage-viewer
|
|||||||
bun install
|
bun install
|
||||||
|
|
||||||
# 3. 启动开发服务器
|
# 3. 启动开发服务器
|
||||||
bun run dev:vite # 仅 Web (http://localhost:3000)
|
bun run dev:vite # 仅 Web (http://localhost:13098)
|
||||||
bun run dev # Tauri 桌面应用 + Web
|
bun run dev # Tauri 桌面应用 + Web
|
||||||
```
|
```
|
||||||
|
|
||||||
### 构建
|
### 构建
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run build:vite # 构建 Web 版本
|
bun run build:vite # 构建 Web 版本 (输出到 .output/)
|
||||||
bun run build # 构建 Tauri 桌面安装包
|
bun run build # 构建 Tauri 桌面安装包
|
||||||
```
|
```
|
||||||
|
|
||||||
构建产物位置:
|
### 构建产物位置
|
||||||
- **MSI 安装包**: `src-tauri/target/release/bundle/msi/app-desktop_0.1.0_x64_en-US.msi`
|
|
||||||
- **NSIS 安装包**: `src-tauri/target/release/bundle/nsis/app-desktop_0.1.0_x64-setup.exe`
|
| 类型 | 路径 |
|
||||||
|
|------|------|
|
||||||
|
| **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` | 启动 Tauri + Vite 开发服务器 (并行) |
|
||||||
| `bun dev:vite` | 仅启动 Vite 开发服务器 |
|
| `bun dev:vite` | 仅启动 Vite 开发服务器 (http://localhost:13098) |
|
||||||
| `bun dev:tauri` | 仅启动 Tauri (需先启动 Vite) |
|
| `bun dev:tauri` | 仅启动 Tauri (需先启动 Vite) |
|
||||||
| `bun build` | 完整构建 (Tauri 桌面应用) |
|
| `bun db:studio` | 打开 Drizzle Studio 数据库管理界面 |
|
||||||
| `bun build:vite` | 仅构建 Web 版本 |
|
|
||||||
|
### 构建
|
||||||
|
|
||||||
|
| 命令 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `bun build` | 完整构建 (Vite → 编译 → Tauri 打包) |
|
||||||
|
| `bun build:vite` | 仅构建 Web 版本 (输出到 .output/) |
|
||||||
|
| `bun build:compile` | 编译 Sidecar 二进制 (使用 build.ts) |
|
||||||
|
| `bun build:tauri` | 构建 Tauri 桌面安装包 |
|
||||||
|
|
||||||
|
### 代码质量
|
||||||
|
|
||||||
|
| 命令 | 说明 |
|
||||||
|
|------|------|
|
||||||
| `bun typecheck` | TypeScript 类型检查 |
|
| `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:13098 与 Sidecar 通信
|
||||||
|
|
||||||
|
### 数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
Token API (http://10.0.1.1:8318/usage)
|
||||||
|
↓
|
||||||
|
ORPC Handler (usage.ts)
|
||||||
|
↓
|
||||||
|
SQLite (data/app.db) ← 历史记录存储
|
||||||
|
↓
|
||||||
|
TanStack Query Cache
|
||||||
|
↓
|
||||||
|
React Components (TokenUsageDashboard)
|
||||||
|
```
|
||||||
|
|
||||||
## 故障排除
|
## 故障排除
|
||||||
|
|
||||||
@@ -152,10 +295,10 @@ rm -rf src-tauri/target
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Windows
|
# Windows
|
||||||
netstat -ano | findstr :3000
|
netstat -ano | findstr :13098
|
||||||
|
|
||||||
# Linux/macOS
|
# Linux/macOS
|
||||||
lsof -i :3000
|
lsof -i :13098
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 5. 重新构建
|
#### 5. 重新构建
|
||||||
@@ -168,7 +311,7 @@ bun run build
|
|||||||
|
|
||||||
#### "An unhandled error happened!" 错误
|
#### "An unhandled error happened!" 错误
|
||||||
|
|
||||||
1. 确保没有其他进程占用端口 3000
|
1. 确保没有其他进程占用端口 13098
|
||||||
2. 尝试完全清理缓存后重新构建
|
2. 尝试完全清理缓存后重新构建
|
||||||
3. 检查 `.env` 文件中的 `TOKEN_USAGE_URL` 配置是否正确
|
3. 检查 `.env` 文件中的 `TOKEN_USAGE_URL` 配置是否正确
|
||||||
|
|
||||||
@@ -176,7 +319,7 @@ bun run build
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# 查找占用端口的进程
|
# 查找占用端口的进程
|
||||||
netstat -ano | findstr :3000
|
netstat -ano | findstr :13098
|
||||||
|
|
||||||
# 结束进程 (替换 PID 为实际进程 ID)
|
# 结束进程 (替换 PID 为实际进程 ID)
|
||||||
taskkill /F /PID <PID>
|
taskkill /F /PID <PID>
|
||||||
@@ -188,34 +331,6 @@ taskkill /F /PID <PID>
|
|||||||
2. 重新安装新的 MSI
|
2. 重新安装新的 MSI
|
||||||
3. 确保安装目录有写入权限
|
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
|
MIT
|
||||||
|
|||||||
88
build.ts
88
build.ts
@@ -1,11 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* 跨平台构建脚本
|
||||||
|
*
|
||||||
|
* 使用 Effect 框架实现类型安全的多目标编译。
|
||||||
|
* 将 TanStack Start 的 SSR 服务器打包为独立可执行文件,
|
||||||
|
* 用于 Tauri 的 sidecar 机制。
|
||||||
|
*
|
||||||
|
* 支持目标平台:
|
||||||
|
* - Windows (x64)
|
||||||
|
* - macOS (ARM64/x64)
|
||||||
|
* - Linux (x64/ARM64)
|
||||||
|
*
|
||||||
|
* 用法: bun run build:compile
|
||||||
|
*/
|
||||||
import { Schema } from '@effect/schema'
|
import { Schema } from '@effect/schema'
|
||||||
import { $ } from 'bun'
|
import { $ } from 'bun'
|
||||||
import { Console, Context, Data, Effect, Layer } from 'effect'
|
import { Console, Context, Data, Effect, Layer } from 'effect'
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Domain Models & Schema
|
// 项目配置
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
/** 项目名称 - 用于生成 sidecar 文件名 */
|
||||||
|
const PROJECT_NAME = 'openbridgeTokenUsageViewerServer'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 领域模型和 Schema 定义
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bun 目标平台到 Rust 目标三元组的映射
|
||||||
|
* 用于生成 Tauri sidecar 所需的文件名格式
|
||||||
|
*/
|
||||||
const targetMap = {
|
const targetMap = {
|
||||||
'bun-windows-x64': 'x86_64-pc-windows-msvc',
|
'bun-windows-x64': 'x86_64-pc-windows-msvc',
|
||||||
'bun-darwin-arm64': 'aarch64-apple-darwin',
|
'bun-darwin-arm64': 'aarch64-apple-darwin',
|
||||||
@@ -14,6 +39,7 @@ const targetMap = {
|
|||||||
'bun-linux-arm64': 'aarch64-unknown-linux-gnu',
|
'bun-linux-arm64': 'aarch64-unknown-linux-gnu',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
/** Bun 编译目标 Schema */
|
||||||
const BunTargetSchema = Schema.Literal(
|
const BunTargetSchema = Schema.Literal(
|
||||||
'bun-windows-x64',
|
'bun-windows-x64',
|
||||||
'bun-darwin-arm64',
|
'bun-darwin-arm64',
|
||||||
@@ -22,48 +48,63 @@ const BunTargetSchema = Schema.Literal(
|
|||||||
'bun-linux-arm64',
|
'bun-linux-arm64',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Bun 编译目标类型 */
|
||||||
type BunTarget = Schema.Schema.Type<typeof BunTargetSchema>
|
type BunTarget = Schema.Schema.Type<typeof BunTargetSchema>
|
||||||
|
|
||||||
|
/** 构建配置 Schema */
|
||||||
const BuildConfigSchema = Schema.Struct({
|
const BuildConfigSchema = Schema.Struct({
|
||||||
|
/** 入口文件路径 */
|
||||||
entrypoint: Schema.String.pipe(Schema.nonEmptyString()),
|
entrypoint: Schema.String.pipe(Schema.nonEmptyString()),
|
||||||
|
/** 输出目录 */
|
||||||
outputDir: Schema.String.pipe(Schema.nonEmptyString()),
|
outputDir: Schema.String.pipe(Schema.nonEmptyString()),
|
||||||
|
/** 目标平台列表 */
|
||||||
targets: Schema.Array(BunTargetSchema).pipe(Schema.minItems(1)),
|
targets: Schema.Array(BunTargetSchema).pipe(Schema.minItems(1)),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 构建配置类型 */
|
||||||
type BuildConfig = Schema.Schema.Type<typeof BuildConfigSchema>
|
type BuildConfig = Schema.Schema.Type<typeof BuildConfigSchema>
|
||||||
|
|
||||||
|
/** 构建结果 Schema */
|
||||||
const BuildResultSchema = Schema.Struct({
|
const BuildResultSchema = Schema.Struct({
|
||||||
|
/** 编译目标 */
|
||||||
target: BunTargetSchema,
|
target: BunTargetSchema,
|
||||||
|
/** 输出文件路径列表 */
|
||||||
outputs: Schema.Array(Schema.String),
|
outputs: Schema.Array(Schema.String),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 构建结果类型 */
|
||||||
type BuildResult = Schema.Schema.Type<typeof BuildResultSchema>
|
type BuildResult = Schema.Schema.Type<typeof BuildResultSchema>
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Error Models (使用 Data.TaggedError)
|
// 错误模型 (使用 Effect Data.TaggedError)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
/** 清理目录错误 */
|
||||||
class CleanError extends Data.TaggedError('CleanError')<{
|
class CleanError extends Data.TaggedError('CleanError')<{
|
||||||
readonly dir: string
|
readonly dir: string
|
||||||
readonly cause: unknown
|
readonly cause: unknown
|
||||||
}> {}
|
}> {}
|
||||||
|
|
||||||
|
/** 构建错误 */
|
||||||
class BuildError extends Data.TaggedError('BuildError')<{
|
class BuildError extends Data.TaggedError('BuildError')<{
|
||||||
readonly target: BunTarget
|
readonly target: BunTarget
|
||||||
readonly cause: unknown
|
readonly cause: unknown
|
||||||
}> {}
|
}> {}
|
||||||
|
|
||||||
|
/** 配置验证错误 */
|
||||||
class ConfigError extends Data.TaggedError('ConfigError')<{
|
class ConfigError extends Data.TaggedError('ConfigError')<{
|
||||||
readonly message: string
|
readonly message: string
|
||||||
readonly cause: unknown
|
readonly cause: unknown
|
||||||
}> {}
|
}> {}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Services
|
// 服务层
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 配置服务
|
* 构建配置服务
|
||||||
|
*
|
||||||
|
* 提供类型安全的配置验证和默认配置。
|
||||||
*/
|
*/
|
||||||
class BuildConfigService extends Context.Tag('BuildConfigService')<
|
class BuildConfigService extends Context.Tag('BuildConfigService')<
|
||||||
BuildConfigService,
|
BuildConfigService,
|
||||||
@@ -71,6 +112,9 @@ class BuildConfigService extends Context.Tag('BuildConfigService')<
|
|||||||
>() {
|
>() {
|
||||||
/**
|
/**
|
||||||
* 从原始数据创建并验证配置
|
* 从原始数据创建并验证配置
|
||||||
|
*
|
||||||
|
* @param raw - 原始配置对象
|
||||||
|
* @returns 验证后的配置或配置错误
|
||||||
*/
|
*/
|
||||||
static fromRaw = (raw: unknown) =>
|
static fromRaw = (raw: unknown) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -89,12 +133,13 @@ class BuildConfigService extends Context.Tag('BuildConfigService')<
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 默认配置 Layer
|
* 默认配置 Layer
|
||||||
|
*
|
||||||
|
* 输出到 src-tauri/binaries 目录,供 Tauri sidecar 使用
|
||||||
*/
|
*/
|
||||||
static readonly Live = Layer.effect(
|
static readonly Live = Layer.effect(
|
||||||
BuildConfigService,
|
BuildConfigService,
|
||||||
BuildConfigService.fromRaw({
|
BuildConfigService.fromRaw({
|
||||||
entrypoint: '.output/server/index.mjs',
|
entrypoint: '.output/server/index.mjs',
|
||||||
// outputDir: 'out',
|
|
||||||
outputDir: 'src-tauri/binaries',
|
outputDir: 'src-tauri/binaries',
|
||||||
targets: ['bun-windows-x64', 'bun-darwin-arm64', 'bun-linux-x64'],
|
targets: ['bun-windows-x64', 'bun-darwin-arm64', 'bun-linux-x64'],
|
||||||
}),
|
}),
|
||||||
@@ -103,10 +148,13 @@ class BuildConfigService extends Context.Tag('BuildConfigService')<
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 文件系统服务
|
* 文件系统服务
|
||||||
|
*
|
||||||
|
* 提供目录清理等文件操作。
|
||||||
*/
|
*/
|
||||||
class FileSystemService extends Context.Tag('FileSystemService')<
|
class FileSystemService extends Context.Tag('FileSystemService')<
|
||||||
FileSystemService,
|
FileSystemService,
|
||||||
{
|
{
|
||||||
|
/** 清理指定目录 */
|
||||||
readonly cleanDir: (dir: string) => Effect.Effect<void, CleanError>
|
readonly cleanDir: (dir: string) => Effect.Effect<void, CleanError>
|
||||||
}
|
}
|
||||||
>() {
|
>() {
|
||||||
@@ -127,14 +175,18 @@ class FileSystemService extends Context.Tag('FileSystemService')<
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建服务
|
* 构建服务
|
||||||
|
*
|
||||||
|
* 使用 Bun.build 进行跨平台编译。
|
||||||
*/
|
*/
|
||||||
class BuildService extends Context.Tag('BuildService')<
|
class BuildService extends Context.Tag('BuildService')<
|
||||||
BuildService,
|
BuildService,
|
||||||
{
|
{
|
||||||
|
/** 为单个目标编译 */
|
||||||
readonly buildForTarget: (
|
readonly buildForTarget: (
|
||||||
config: BuildConfig,
|
config: BuildConfig,
|
||||||
target: BunTarget,
|
target: BunTarget,
|
||||||
) => Effect.Effect<BuildResult, BuildError>
|
) => Effect.Effect<BuildResult, BuildError>
|
||||||
|
/** 并行编译所有目标 */
|
||||||
readonly buildAll: (
|
readonly buildAll: (
|
||||||
config: BuildConfig,
|
config: BuildConfig,
|
||||||
) => Effect.Effect<ReadonlyArray<BuildResult>, BuildError>
|
) => Effect.Effect<ReadonlyArray<BuildResult>, BuildError>
|
||||||
@@ -150,7 +202,7 @@ class BuildService extends Context.Tag('BuildService')<
|
|||||||
Bun.build({
|
Bun.build({
|
||||||
entrypoints: [config.entrypoint],
|
entrypoints: [config.entrypoint],
|
||||||
compile: {
|
compile: {
|
||||||
outfile: `app-${targetMap[target]}`,
|
outfile: `${PROJECT_NAME}-${targetMap[target]}`,
|
||||||
target: target,
|
target: target,
|
||||||
},
|
},
|
||||||
outdir: config.outputDir,
|
outdir: config.outputDir,
|
||||||
@@ -172,6 +224,7 @@ class BuildService extends Context.Tag('BuildService')<
|
|||||||
|
|
||||||
buildAll: (config: BuildConfig) =>
|
buildAll: (config: BuildConfig) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
// 为每个目标创建编译任务
|
||||||
const effects = config.targets.map((target) =>
|
const effects = config.targets.map((target) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* Console.log(`🔨 开始构建: ${target}`)
|
yield* Console.log(`🔨 开始构建: ${target}`)
|
||||||
@@ -181,7 +234,7 @@ class BuildService extends Context.Tag('BuildService')<
|
|||||||
Bun.build({
|
Bun.build({
|
||||||
entrypoints: [config.entrypoint],
|
entrypoints: [config.entrypoint],
|
||||||
compile: {
|
compile: {
|
||||||
outfile: `app-${targetMap[target]}`,
|
outfile: `${PROJECT_NAME}-${targetMap[target]}`,
|
||||||
target: target,
|
target: target,
|
||||||
},
|
},
|
||||||
outdir: config.outputDir,
|
outdir: config.outputDir,
|
||||||
@@ -203,6 +256,7 @@ class BuildService extends Context.Tag('BuildService')<
|
|||||||
} satisfies BuildResult
|
} satisfies BuildResult
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
// 并行执行所有编译任务
|
||||||
return yield* Effect.all(effects, { concurrency: 'unbounded' })
|
return yield* Effect.all(effects, { concurrency: 'unbounded' })
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -210,10 +264,13 @@ class BuildService extends Context.Tag('BuildService')<
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 报告服务
|
* 报告服务
|
||||||
|
*
|
||||||
|
* 输出构建结果摘要。
|
||||||
*/
|
*/
|
||||||
class ReporterService extends Context.Tag('ReporterService')<
|
class ReporterService extends Context.Tag('ReporterService')<
|
||||||
ReporterService,
|
ReporterService,
|
||||||
{
|
{
|
||||||
|
/** 打印构建摘要 */
|
||||||
readonly printSummary: (
|
readonly printSummary: (
|
||||||
results: ReadonlyArray<BuildResult>,
|
results: ReadonlyArray<BuildResult>,
|
||||||
) => Effect.Effect<void>
|
) => Effect.Effect<void>
|
||||||
@@ -234,9 +291,17 @@ class ReporterService extends Context.Tag('ReporterService')<
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Main Program
|
// 主程序
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建流程主程序
|
||||||
|
*
|
||||||
|
* 步骤:
|
||||||
|
* 1. 清理输出目录
|
||||||
|
* 2. 并行构建所有目标平台
|
||||||
|
* 3. 输出构建摘要
|
||||||
|
*/
|
||||||
const program = Effect.gen(function* () {
|
const program = Effect.gen(function* () {
|
||||||
const config = yield* BuildConfigService
|
const config = yield* BuildConfigService
|
||||||
const fs = yield* FileSystemService
|
const fs = yield* FileSystemService
|
||||||
@@ -257,9 +322,10 @@ const program = Effect.gen(function* () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Layer Composition
|
// Layer 组合
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
/** 合并所有服务 Layer */
|
||||||
const MainLayer = Layer.mergeAll(
|
const MainLayer = Layer.mergeAll(
|
||||||
BuildConfigService.Live,
|
BuildConfigService.Live,
|
||||||
FileSystemService.Live,
|
FileSystemService.Live,
|
||||||
@@ -268,9 +334,10 @@ const MainLayer = Layer.mergeAll(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Runner
|
// 执行入口
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
/** 可运行的程序(附带错误处理) */
|
||||||
const runnable = program.pipe(
|
const runnable = program.pipe(
|
||||||
Effect.provide(MainLayer),
|
Effect.provide(MainLayer),
|
||||||
Effect.catchTags({
|
Effect.catchTags({
|
||||||
@@ -284,6 +351,7 @@ const runnable = program.pipe(
|
|||||||
Effect.tapErrorCause((cause) => Console.error('❌ 未预期的错误:', cause)),
|
Effect.tapErrorCause((cause) => Console.error('❌ 未预期的错误:', cause)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 执行程序
|
||||||
Effect.runPromise(runnable).catch(() => {
|
Effect.runPromise(runnable).catch(() => {
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
})
|
})
|
||||||
|
|||||||
99
bun.lock
99
bun.lock
@@ -5,21 +5,31 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "fullstack-starter",
|
"name": "fullstack-starter",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@effect/schema": "^0.75.5",
|
||||||
|
"@oicl/openbridge-webcomponents-react": "^0.0.17",
|
||||||
"@orpc/client": "^1.13.4",
|
"@orpc/client": "^1.13.4",
|
||||||
"@orpc/contract": "^1.13.4",
|
"@orpc/contract": "^1.13.4",
|
||||||
"@orpc/server": "^1.13.4",
|
"@orpc/server": "^1.13.4",
|
||||||
"@orpc/tanstack-query": "^1.13.4",
|
"@orpc/tanstack-query": "^1.13.4",
|
||||||
"@orpc/zod": "^1.13.4",
|
"@orpc/zod": "^1.13.4",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@t3-oss/env-core": "^0.13.10",
|
"@t3-oss/env-core": "^0.13.10",
|
||||||
"@tanstack/react-query": "^5.90.18",
|
"@tanstack/react-query": "^5.90.18",
|
||||||
"@tanstack/react-router": "^1.151.0",
|
"@tanstack/react-router": "^1.151.0",
|
||||||
"@tanstack/react-router-ssr-query": "^1.151.0",
|
"@tanstack/react-router-ssr-query": "^1.151.0",
|
||||||
"@tanstack/react-start": "^1.151.0",
|
"@tanstack/react-start": "^1.151.0",
|
||||||
"@tauri-apps/api": "^2.9.1",
|
"@tauri-apps/api": "^2.9.1",
|
||||||
|
"@types/react": "^19.2.9",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"drizzle-zod": "^0.8.3",
|
"drizzle-zod": "^0.8.3",
|
||||||
|
"lucide-react": "^0.563.0",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
"zod": "^4.3.5",
|
"zod": "^4.3.5",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -38,6 +48,7 @@
|
|||||||
"nitro": "npm:nitro-nightly@latest",
|
"nitro": "npm:nitro-nightly@latest",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"turbo": "^2.7.5",
|
"turbo": "^2.7.5",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^8.0.0-beta.8",
|
"vite": "^8.0.0-beta.8",
|
||||||
"vite-tsconfig-paths": "^6.0.4",
|
"vite-tsconfig-paths": "^6.0.4",
|
||||||
@@ -129,6 +140,8 @@
|
|||||||
|
|
||||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
"@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/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=="],
|
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||||
@@ -201,8 +214,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=="],
|
"@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=="],
|
"@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/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=="],
|
"@oozcitak/infra": ["@oozcitak/infra@2.0.2", "", { "dependencies": { "@oozcitak/util": "^10.0.0" } }, "sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA=="],
|
||||||
@@ -327,6 +354,40 @@
|
|||||||
|
|
||||||
"@oxc-transform/binding-win32-x64-msvc": ["@oxc-transform/binding-win32-x64-msvc@0.108.0", "", { "os": "win32", "cpu": "x64" }, "sha512-k+7tuCMULfB7zr57jb68sVzxbyleZBasyr1h1Ieiu1U95XHYe64pbSrwHmlaSmiNHqV91ikM3809+ps68jZZhw=="],
|
"@oxc-transform/binding-win32-x64-msvc": ["@oxc-transform/binding-win32-x64-msvc@0.108.0", "", { "os": "win32", "cpu": "x64" }, "sha512-k+7tuCMULfB7zr57jb68sVzxbyleZBasyr1h1Ieiu1U95XHYe64pbSrwHmlaSmiNHqV91ikM3809+ps68jZZhw=="],
|
||||||
|
|
||||||
|
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
|
||||||
|
|
||||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-beta.60", "", { "os": "android", "cpu": "arm64" }, "sha512-hOW6iQXtpG4uCW1zGK56+KhEXGttSkTp2ykncW/nkOIF/jOKTqbM944Q73HVeMXP1mPRvE2cZwNp3xeLIeyIGQ=="],
|
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-beta.60", "", { "os": "android", "cpu": "arm64" }, "sha512-hOW6iQXtpG4uCW1zGK56+KhEXGttSkTp2ykncW/nkOIF/jOKTqbM944Q73HVeMXP1mPRvE2cZwNp3xeLIeyIGQ=="],
|
||||||
|
|
||||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-beta.60", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vyDA4HXY2mP8PPtl5UE17uGPxUNG4m1wkfa3kAkR8JWrFbarV97UmLq22IWrNhtBPa89xqerzLK8KoVmz5JqCQ=="],
|
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-beta.60", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vyDA4HXY2mP8PPtl5UE17uGPxUNG4m1wkfa3kAkR8JWrFbarV97UmLq22IWrNhtBPa89xqerzLK8KoVmz5JqCQ=="],
|
||||||
@@ -557,10 +618,12 @@
|
|||||||
|
|
||||||
"@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="],
|
"@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/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=="],
|
"@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=="],
|
"@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=="],
|
||||||
@@ -575,6 +638,8 @@
|
|||||||
|
|
||||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||||
|
|
||||||
|
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||||
|
|
||||||
"ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
|
"ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
|
||||||
|
|
||||||
"babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="],
|
"babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="],
|
||||||
@@ -609,6 +674,8 @@
|
|||||||
|
|
||||||
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||||
|
|
||||||
|
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||||
|
|
||||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
|
|
||||||
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
|
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
|
||||||
@@ -639,6 +706,8 @@
|
|||||||
|
|
||||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
|
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||||
|
|
||||||
"diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
"diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||||
|
|
||||||
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
||||||
@@ -689,6 +758,8 @@
|
|||||||
|
|
||||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||||
|
|
||||||
|
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||||
|
|
||||||
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
|
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
|
||||||
|
|
||||||
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
@@ -771,6 +842,12 @@
|
|||||||
|
|
||||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
|
"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.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
|
||||||
|
|
||||||
"lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="],
|
"lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="],
|
||||||
@@ -787,6 +864,8 @@
|
|||||||
|
|
||||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||||
|
|
||||||
|
"lucide-react": ["lucide-react@0.563.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA=="],
|
||||||
|
|
||||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
@@ -843,6 +922,12 @@
|
|||||||
|
|
||||||
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
|
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
|
||||||
|
|
||||||
|
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
|
||||||
|
|
||||||
|
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||||
|
|
||||||
|
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||||
|
|
||||||
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||||
|
|
||||||
"recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
|
"recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
|
||||||
@@ -883,6 +968,8 @@
|
|||||||
|
|
||||||
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
||||||
|
|
||||||
|
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
||||||
|
|
||||||
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||||
|
|
||||||
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||||
@@ -915,6 +1002,8 @@
|
|||||||
|
|
||||||
"turbo-windows-arm64": ["turbo-windows-arm64@2.7.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-G377Gxn6P42RnCzfMyDvsqQV7j69kVHKlhz9J4RhtJOB5+DyY4yYh/w0oTIxZQ4JRMmhjwLu3w9zncMoQ6nNDw=="],
|
"turbo-windows-arm64": ["turbo-windows-arm64@2.7.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-G377Gxn6P42RnCzfMyDvsqQV7j69kVHKlhz9J4RhtJOB5+DyY4yYh/w0oTIxZQ4JRMmhjwLu3w9zncMoQ6nNDw=="],
|
||||||
|
|
||||||
|
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||||
|
|
||||||
"type-fest": ["type-fest@5.4.1", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-xygQcmneDyzsEuKZrFbRMne5HDqMs++aFzefrJTgEIKjQ3rekM+RPfFCVq2Gp1VIDqddoYeppCj4Pcb+RZW0GQ=="],
|
"type-fest": ["type-fest@5.4.1", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-xygQcmneDyzsEuKZrFbRMne5HDqMs++aFzefrJTgEIKjQ3rekM+RPfFCVq2Gp1VIDqddoYeppCj4Pcb+RZW0GQ=="],
|
||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
@@ -933,6 +1022,10 @@
|
|||||||
|
|
||||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||||
|
|
||||||
|
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
|
||||||
|
|
||||||
|
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
|
||||||
|
|
||||||
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||||
|
|
||||||
"uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
"uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
||||||
@@ -963,6 +1056,10 @@
|
|||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||||
|
|||||||
22
components.json
Normal file
22
components.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/styles.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "fullstack-starter",
|
"name": "openbridgeTokenUsageViewer",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "bun@1.3.6",
|
"packageManager": "bun@1.3.6",
|
||||||
@@ -27,6 +27,8 @@
|
|||||||
"@orpc/server": "^1.13.4",
|
"@orpc/server": "^1.13.4",
|
||||||
"@orpc/tanstack-query": "^1.13.4",
|
"@orpc/tanstack-query": "^1.13.4",
|
||||||
"@orpc/zod": "^1.13.4",
|
"@orpc/zod": "^1.13.4",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@t3-oss/env-core": "^0.13.10",
|
"@t3-oss/env-core": "^0.13.10",
|
||||||
"@tanstack/react-query": "^5.90.18",
|
"@tanstack/react-query": "^5.90.18",
|
||||||
"@tanstack/react-router": "^1.151.0",
|
"@tanstack/react-router": "^1.151.0",
|
||||||
@@ -35,10 +37,14 @@
|
|||||||
"@tauri-apps/api": "^2.9.1",
|
"@tauri-apps/api": "^2.9.1",
|
||||||
"@types/react": "^19.2.9",
|
"@types/react": "^19.2.9",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"drizzle-zod": "^0.8.3",
|
"drizzle-zod": "^0.8.3",
|
||||||
|
"lucide-react": "^0.563.0",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -57,6 +63,7 @@
|
|||||||
"nitro": "npm:nitro-nightly@latest",
|
"nitro": "npm:nitro-nightly@latest",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"turbo": "^2.7.5",
|
"turbo": "^2.7.5",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^8.0.0-beta.8",
|
"vite": "^8.0.0-beta.8",
|
||||||
"vite-tsconfig-paths": "^6.0.4"
|
"vite-tsconfig-paths": "^6.0.4"
|
||||||
|
|||||||
2
src-tauri/.gitignore
vendored
2
src-tauri/.gitignore
vendored
@@ -8,3 +8,5 @@
|
|||||||
|
|
||||||
# Tauri Sidecar
|
# Tauri Sidecar
|
||||||
binaries/
|
binaries/
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
- **后端**: Rust (Edition 2021)
|
- **后端**: Rust (Edition 2021)
|
||||||
- **架构**: Sidecar 模式 - Sidecar App 承载主要业务逻辑
|
- **架构**: Sidecar 模式 - Sidecar App 承载主要业务逻辑
|
||||||
- **设计理念**: Tauri 仅提供原生桌面能力(文件对话框、系统通知等),Web 逻辑全部由 Sidecar App 处理
|
- **设计理念**: Tauri 仅提供原生桌面能力(文件对话框、系统通知等),Web 逻辑全部由 Sidecar App 处理
|
||||||
- **开发模式**: 使用 localhost:3000(需手动启动开发服务器)
|
- **开发模式**: 使用 localhost:13098(需手动启动开发服务器)
|
||||||
- **生产模式**: 自动启动 Sidecar 二进制
|
- **生产模式**: 自动启动 Sidecar 二进制
|
||||||
- **异步运行时**: Tokio
|
- **异步运行时**: Tokio
|
||||||
- **Rust 版本**: 1.92.0+
|
- **Rust 版本**: 1.92.0+
|
||||||
@@ -30,7 +30,7 @@ bun run dev:tauri
|
|||||||
```
|
```
|
||||||
|
|
||||||
**开发模式说明**:
|
**开发模式说明**:
|
||||||
- 开发模式下,Tauri 直接连接到 `localhost:3000`(不启动 sidecar 二进制)
|
- 开发模式下,Tauri 直接连接到 `localhost:13098`(不启动 sidecar 二进制)
|
||||||
- 需要手动运行 `bun run dev` 来启动开发服务器
|
- 需要手动运行 `bun run dev` 来启动开发服务器
|
||||||
- 支持热重载(HMR),无需重启 Tauri 应用
|
- 支持热重载(HMR),无需重启 Tauri 应用
|
||||||
|
|
||||||
@@ -141,12 +141,12 @@ use tauri::*;
|
|||||||
```rust
|
```rust
|
||||||
// ✅ 推荐
|
// ✅ 推荐
|
||||||
struct SidecarProcess(Mutex<Option<CommandChild>>);
|
struct SidecarProcess(Mutex<Option<CommandChild>>);
|
||||||
const DEFAULT_PORT: u16 = 3000;
|
const DEFAULT_PORT: u16 = 13098;
|
||||||
async fn find_available_port(start: u16) -> u16 { }
|
async fn find_available_port(start: u16) -> u16 { }
|
||||||
|
|
||||||
// ❌ 避免
|
// ❌ 避免
|
||||||
struct sidecar_process { }
|
struct sidecar_process { }
|
||||||
const defaultPort: u16 = 3000;
|
const defaultPort: u16 = 13098;
|
||||||
```
|
```
|
||||||
|
|
||||||
### 类型注解
|
### 类型注解
|
||||||
@@ -205,7 +205,7 @@ let data = read_file().unwrap(); // 无上下文信息
|
|||||||
```rust
|
```rust
|
||||||
// ✅ 推荐
|
// ✅ 推荐
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
let port = find_available_port(3000).await;
|
let port = find_available_port(13098).await;
|
||||||
// ...
|
// ...
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
@@ -339,7 +339,7 @@ tokio = { version = "1", features = ["net"] }
|
|||||||
- 生产构建自动打包 sidecar 二进制,无需额外配置
|
- 生产构建自动打包 sidecar 二进制,无需额外配置
|
||||||
2. **进程生命周期**: 始终在应用退出时清理子进程和资源
|
2. **进程生命周期**: 始终在应用退出时清理子进程和资源
|
||||||
3. **端口管理**:
|
3. **端口管理**:
|
||||||
- 开发模式固定使用 3000 端口(与开发服务器匹配)
|
- 开发模式固定使用 13098 端口(与开发服务器匹配)
|
||||||
- 生产模式使用端口扫描避免硬编码端口冲突
|
- 生产模式使用端口扫描避免硬编码端口冲突
|
||||||
4. **超时处理**: 异步操作设置合理的超时时间 (如 5 秒)
|
4. **超时处理**: 异步操作设置合理的超时时间 (如 5 秒)
|
||||||
5. **日志**: 使用表情符号 (✓/✗/🔧/🚀) 和中文消息提供清晰的状态反馈
|
5. **日志**: 使用表情符号 (✓/✗/🔧/🚀) 和中文消息提供清晰的状态反馈
|
||||||
|
|||||||
22
src-tauri/Cargo.lock
generated
22
src-tauri/Cargo.lock
generated
@@ -47,17 +47,6 @@ version = "1.0.100"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "app-desktop"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
"tauri",
|
|
||||||
"tauri-build",
|
|
||||||
"tauri-plugin-shell",
|
|
||||||
"tokio",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atk"
|
name = "atk"
|
||||||
version = "0.18.2"
|
version = "0.18.2"
|
||||||
@@ -2071,6 +2060,17 @@ dependencies = [
|
|||||||
"pathdiff",
|
"pathdiff",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openbridgeTokenUsageViewer"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"tauri",
|
||||||
|
"tauri-build",
|
||||||
|
"tauri-plugin-shell",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "option-ext"
|
name = "option-ext"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "app-desktop"
|
name = "openbridgeTokenUsageViewer"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "A Tauri App"
|
description = "OpenBridge Token Usage Viewer"
|
||||||
authors = ["imbytecat"]
|
authors = ["imbytecat"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ edition = "2021"
|
|||||||
# The `_lib` suffix may seem redundant but it is necessary
|
# The `_lib` suffix may seem redundant but it is necessary
|
||||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||||
name = "app_desktop_lib"
|
name = "openbridgeTokenUsageViewer_lib"
|
||||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
app_desktop_lib::run()
|
openbridgeTokenUsageViewer_lib::run()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,27 @@ use tauri::Manager;
|
|||||||
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
||||||
use tauri_plugin_shell::ShellExt;
|
use tauri_plugin_shell::ShellExt;
|
||||||
|
|
||||||
|
// ===== 项目配置 =====
|
||||||
|
|
||||||
|
/// Sidecar 二进制名称
|
||||||
|
const SIDECAR_NAME: &str = "openbridgeTokenUsageViewerServer";
|
||||||
|
|
||||||
|
/// 默认服务器端口
|
||||||
|
const DEFAULT_PORT: u16 = 13098;
|
||||||
|
|
||||||
|
/// 从环境变量获取端口 (PROJECT_SERVER_PORT),默认 13098
|
||||||
|
fn get_project_port() -> u16 {
|
||||||
|
std::env::var("PROJECT_SERVER_PORT")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(DEFAULT_PORT)
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 配置常量 =====
|
// ===== 配置常量 =====
|
||||||
|
|
||||||
/// Sidecar App 启动超时时间(秒)
|
/// Sidecar App 启动超时时间(秒)
|
||||||
const STARTUP_TIMEOUT_SECS: u64 = 30;
|
const STARTUP_TIMEOUT_SECS: u64 = 30;
|
||||||
|
|
||||||
/// 默认起始端口
|
|
||||||
const DEFAULT_PORT: u16 = 3000;
|
|
||||||
|
|
||||||
/// 端口扫描范围(从起始端口开始扫描的端口数量)
|
/// 端口扫描范围(从起始端口开始扫描的端口数量)
|
||||||
const PORT_SCAN_RANGE: u16 = 100;
|
const PORT_SCAN_RANGE: u16 = 100;
|
||||||
|
|
||||||
@@ -23,7 +36,7 @@ const DEFAULT_WINDOW_WIDTH: f64 = 1200.0;
|
|||||||
const DEFAULT_WINDOW_HEIGHT: f64 = 800.0;
|
const DEFAULT_WINDOW_HEIGHT: f64 = 800.0;
|
||||||
|
|
||||||
/// 窗口标题
|
/// 窗口标题
|
||||||
const WINDOW_TITLE: &str = "Tauri App";
|
const WINDOW_TITLE: &str = "OpenBridge Token Usage Viewer";
|
||||||
|
|
||||||
// ===== 数据结构 =====
|
// ===== 数据结构 =====
|
||||||
|
|
||||||
@@ -72,15 +85,19 @@ fn show_error_dialog(message: &str) {
|
|||||||
pub fn spawn_sidecar(app_handle: tauri::AppHandle) {
|
pub fn spawn_sidecar(app_handle: tauri::AppHandle) {
|
||||||
// 检测是否为开发模式
|
// 检测是否为开发模式
|
||||||
let is_dev = cfg!(debug_assertions);
|
let is_dev = cfg!(debug_assertions);
|
||||||
|
// 获取项目专用端口
|
||||||
|
let project_port = get_project_port();
|
||||||
|
|
||||||
if is_dev {
|
if is_dev {
|
||||||
// 开发模式:直接创建窗口连接到 Vite 开发服务器
|
// 开发模式:直接创建窗口连接到 Vite 开发服务器
|
||||||
println!("🔧 开发模式");
|
println!("🔧 开发模式");
|
||||||
|
println!("📌 端口: {}", project_port);
|
||||||
|
|
||||||
|
let dev_url = format!("http://localhost:{}", project_port);
|
||||||
match tauri::WebviewWindowBuilder::new(
|
match tauri::WebviewWindowBuilder::new(
|
||||||
&app_handle,
|
&app_handle,
|
||||||
"main",
|
"main",
|
||||||
tauri::WebviewUrl::External("http://localhost:3000".parse().unwrap()),
|
tauri::WebviewUrl::External(dev_url.parse().unwrap()),
|
||||||
)
|
)
|
||||||
.title(WINDOW_TITLE)
|
.title(WINDOW_TITLE)
|
||||||
.inner_size(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT)
|
.inner_size(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT)
|
||||||
@@ -100,12 +117,12 @@ pub fn spawn_sidecar(app_handle: tauri::AppHandle) {
|
|||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
println!("🚀 生产模式");
|
println!("🚀 生产模式");
|
||||||
|
|
||||||
// 查找可用端口
|
// 查找可用端口 (从项目端口开始扫描)
|
||||||
let port = find_available_port(DEFAULT_PORT).await;
|
let port = find_available_port(project_port).await;
|
||||||
println!("使用端口: {}", port);
|
println!("📌 端口: {}", port);
|
||||||
|
|
||||||
// 启动 sidecar
|
// 启动 sidecar
|
||||||
let sidecar = match app_handle.shell().sidecar("app") {
|
let sidecar = match app_handle.shell().sidecar(SIDECAR_NAME) {
|
||||||
Ok(cmd) => cmd.env("PORT", port.to_string()),
|
Ok(cmd) => cmd.env("PORT", port.to_string()),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("✗ 无法找到 sidecar: {}", e);
|
eprintln!("✗ 无法找到 sidecar: {}", e);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "app-desktop",
|
"productName": "openbridgeTokenUsageViewer",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"identifier": "com.imbytecat.app-desktop",
|
"identifier": "com.imbytecat.openbridgetokenusageviewer",
|
||||||
"app": {
|
"app": {
|
||||||
"withGlobalTauri": true,
|
"withGlobalTauri": true,
|
||||||
"windows": [],
|
"windows": [],
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": "all",
|
"targets": ["msi"],
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
@@ -20,6 +20,6 @@
|
|||||||
"icons/icon.icns",
|
"icons/icon.icns",
|
||||||
"icons/icon.ico"
|
"icons/icon.ico"
|
||||||
],
|
],
|
||||||
"externalBin": ["binaries/app"]
|
"externalBin": ["binaries/openbridgeTokenUsageViewerServer"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 组件
|
* HealthRing 组件
|
||||||
*
|
*
|
||||||
* Apple 健康风格的圆环进度指示器
|
* Apple 健康风格的圆环进度指示器,用于可视化配额使用情况。
|
||||||
* 中心显示倒计时
|
* 中心显示百分比和倒计时,颜色根据剩余配额自动变化。
|
||||||
*/
|
*/
|
||||||
|
import { useMemo } from 'react'
|
||||||
import { useCountdown } from '@/hooks/useCountdown'
|
import { useCountdown } from '@/hooks/useCountdown'
|
||||||
|
|
||||||
|
/** 组件 Props 类型定义 */
|
||||||
export interface HealthRingProps {
|
export interface HealthRingProps {
|
||||||
/** 账户名称 */
|
/** 账户名称 */
|
||||||
account: string
|
account: string
|
||||||
@@ -17,28 +19,36 @@ export interface HealthRingProps {
|
|||||||
remainingFraction: number
|
remainingFraction: number
|
||||||
/** 配额重置时间 (ISO 8601) */
|
/** 配额重置时间 (ISO 8601) */
|
||||||
resetTime?: string
|
resetTime?: string
|
||||||
/** 圆环尺寸 */
|
/** 圆环尺寸 (像素),默认 160 */
|
||||||
size?: number
|
size?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据剩余配额获取颜色
|
* 颜色阈值配置
|
||||||
|
* 根据剩余配额百分比决定显示颜色
|
||||||
*/
|
*/
|
||||||
const getRingColor = (fraction: number): string => {
|
const COLOR_THRESHOLDS = [
|
||||||
if (fraction < 0.05) return '#FF3B30' // 红色 - 紧急
|
{ threshold: 0.05, color: '#FF3B30', bgColor: 'rgba(255, 59, 48, 0.2)' }, // 红色 - 紧急
|
||||||
if (fraction < 0.2) return '#FF9500' // 橙色 - 警告
|
{ threshold: 0.2, color: '#FF9500', bgColor: 'rgba(255, 149, 0, 0.2)' }, // 橙色 - 警告
|
||||||
if (fraction < 0.5) return '#FFCC00' // 黄色 - 注意
|
{ threshold: 0.5, color: '#FFCC00', bgColor: 'rgba(255, 204, 0, 0.2)' }, // 黄色 - 注意
|
||||||
return '#34C759' // 绿色 - 正常
|
] as const
|
||||||
}
|
|
||||||
|
/** 默认颜色 (绿色 - 正常状态) */
|
||||||
|
const DEFAULT_COLOR = { color: '#34C759', bgColor: 'rgba(52, 199, 89, 0.2)' }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据剩余配额获取背景色(较暗)
|
* 根据剩余配额获取对应的颜色配置
|
||||||
|
*
|
||||||
|
* @param fraction - 剩余配额百分比 (0-1)
|
||||||
|
* @returns 前景色和背景色配置
|
||||||
*/
|
*/
|
||||||
const getRingBgColor = (fraction: number): string => {
|
const getColorConfig = (
|
||||||
if (fraction < 0.05) return 'rgba(255, 59, 48, 0.2)'
|
fraction: number,
|
||||||
if (fraction < 0.2) return 'rgba(255, 149, 0, 0.2)'
|
): { color: string; bgColor: string } => {
|
||||||
if (fraction < 0.5) return 'rgba(255, 204, 0, 0.2)'
|
for (const { threshold, color, bgColor } of COLOR_THRESHOLDS) {
|
||||||
return 'rgba(52, 199, 89, 0.2)'
|
if (fraction < threshold) return { color, bgColor }
|
||||||
|
}
|
||||||
|
return DEFAULT_COLOR
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HealthRing = ({
|
export const HealthRing = ({
|
||||||
@@ -50,20 +60,25 @@ export const HealthRing = ({
|
|||||||
size = 160,
|
size = 160,
|
||||||
}: HealthRingProps) => {
|
}: HealthRingProps) => {
|
||||||
const countdown = useCountdown(resetTime)
|
const countdown = useCountdown(resetTime)
|
||||||
const percentage = Math.round(remainingFraction * 100)
|
|
||||||
|
|
||||||
// SVG 参数
|
// 使用 useMemo 缓存 SVG 计算值,避免不必要的重新计算
|
||||||
|
const svgParams = useMemo(() => {
|
||||||
const strokeWidth = size * 0.1
|
const strokeWidth = size * 0.1
|
||||||
const radius = (size - strokeWidth) / 2
|
const radius = (size - strokeWidth) / 2
|
||||||
const circumference = 2 * Math.PI * radius
|
const circumference = 2 * Math.PI * radius
|
||||||
const strokeDashoffset = circumference * (1 - remainingFraction)
|
const strokeDashoffset = circumference * (1 - remainingFraction)
|
||||||
|
|
||||||
const ringColor = getRingColor(remainingFraction)
|
return { strokeWidth, radius, circumference, strokeDashoffset }
|
||||||
const ringBgColor = getRingBgColor(remainingFraction)
|
}, [size, remainingFraction])
|
||||||
|
|
||||||
|
const percentage = Math.round(remainingFraction * 100)
|
||||||
|
const { color: ringColor, bgColor: ringBgColor } =
|
||||||
|
getColorConfig(remainingFraction)
|
||||||
|
const { strokeWidth, radius, circumference, strokeDashoffset } = svgParams
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
{/* 圆环 */}
|
{/* 圆环容器 */}
|
||||||
<div className="relative" style={{ width: size, height: size }}>
|
<div className="relative" style={{ width: size, height: size }}>
|
||||||
<svg
|
<svg
|
||||||
width={size}
|
width={size}
|
||||||
@@ -97,27 +112,27 @@ export const HealthRing = ({
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{/* 中心内容 - 倒计时 */}
|
{/* 中心内容 - 百分比和倒计时 */}
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||||
<span className="text-2xl font-bold" style={{ color: ringColor }}>
|
<span className="text-2xl font-bold" style={{ color: ringColor }}>
|
||||||
{percentage}%
|
{percentage}%
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-[var(--on-container-color)] opacity-70">
|
<span className="text-sm text-[var(--element-inactive-color,#707070)]">
|
||||||
{countdown.formatted}
|
{countdown.formatted}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 标签 */}
|
{/* 底部标签 */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div
|
<div
|
||||||
className="text-sm font-medium truncate max-w-[140px] text-[var(--on-container-color)]"
|
className="text-sm font-medium truncate max-w-[140px] text-[var(--element-active-color,#3d3d3d)]"
|
||||||
title={displayName || model}
|
title={displayName || model}
|
||||||
>
|
>
|
||||||
{displayName || 'Claude Opus 4.5'}
|
{displayName || 'Claude Opus 4.5'}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="text-xs truncate max-w-[140px] text-[var(--on-container-color)] opacity-50"
|
className="text-xs truncate max-w-[140px] text-[var(--element-inactive-color,#707070)]"
|
||||||
title={account}
|
title={account}
|
||||||
>
|
>
|
||||||
{account}
|
{account}
|
||||||
|
|||||||
@@ -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,65 +1,39 @@
|
|||||||
/**
|
/**
|
||||||
* TokenUsageDashboard 组件
|
* TokenUsageDashboard 组件
|
||||||
*
|
*
|
||||||
* 主仪表盘,展示 4 个账户的 claude-opus-4-5-thinking 配额使用情况
|
* 主仪表盘,展示多个账户的 claude-opus-4-5-thinking 配额使用情况。
|
||||||
* 使用 OpenBridge TopBar + AlertTopbarElement
|
* 使用自定义组件替代 OpenBridge 组件,基于 shadcn/ui 实现。
|
||||||
|
*
|
||||||
|
* 特性:
|
||||||
|
* - 多账户配额可视化 (根据 API 返回的账户数量动态显示)
|
||||||
|
* - 实时告警通知 (低于 20% 警告,低于 5% 紧急)
|
||||||
|
* - 支持四种主题切换 (day/bright/dusk/night)
|
||||||
*/
|
*/
|
||||||
import '@oicl/openbridge-webcomponents/dist/icons/icon-palette-day.js'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
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 { HealthRing } from '@/components/HealthRing'
|
||||||
|
import {
|
||||||
|
AlertBadge,
|
||||||
|
AlertNotification,
|
||||||
|
AlertType,
|
||||||
|
} from '@/components/ui/AlertBadge'
|
||||||
|
import { ThemeSidebar } from '@/components/ui/ThemeSidebar'
|
||||||
|
import { TopBar } from '@/components/ui/TopBar'
|
||||||
import { useTheme } from '@/hooks/useTheme'
|
import { useTheme } from '@/hooks/useTheme'
|
||||||
import type { ModelUsage } from '@/orpc/contracts/usage'
|
import type { ModelUsage } from '@/orpc/contracts/usage'
|
||||||
|
|
||||||
// 懒加载 OpenBridge 组件以避免 SSR 问题
|
// ============================================================================
|
||||||
const ObcTopBar = lazy(() =>
|
// 类型定义
|
||||||
import(
|
// ============================================================================
|
||||||
'@oicl/openbridge-webcomponents-react/components/top-bar/top-bar'
|
|
||||||
).then((mod) => ({ default: mod.ObcTopBar })),
|
|
||||||
)
|
|
||||||
|
|
||||||
const ObcAlertTopbarElement = lazy(() =>
|
|
||||||
import(
|
|
||||||
'@oicl/openbridge-webcomponents-react/components/alert-topbar-element/alert-topbar-element'
|
|
||||||
).then((mod) => ({ default: mod.ObcAlertTopbarElement })),
|
|
||||||
)
|
|
||||||
|
|
||||||
const ObcNotificationMessageItem = lazy(() =>
|
|
||||||
import(
|
|
||||||
'@oicl/openbridge-webcomponents-react/components/notification-message-item/notification-message-item'
|
|
||||||
).then((mod) => ({ default: mod.ObcNotificationMessageItem })),
|
|
||||||
)
|
|
||||||
|
|
||||||
const ObcAlertIcon = lazy(() =>
|
|
||||||
import(
|
|
||||||
'@oicl/openbridge-webcomponents-react/components/alert-icon/alert-icon'
|
|
||||||
).then((mod) => ({ default: mod.ObcAlertIcon })),
|
|
||||||
)
|
|
||||||
|
|
||||||
const ObcNavigationMenu = lazy(() =>
|
|
||||||
import(
|
|
||||||
'@oicl/openbridge-webcomponents-react/components/navigation-menu/navigation-menu'
|
|
||||||
).then((mod) => ({ default: mod.ObcNavigationMenu })),
|
|
||||||
)
|
|
||||||
|
|
||||||
const ObcNavigationItem = lazy(() =>
|
|
||||||
import(
|
|
||||||
'@oicl/openbridge-webcomponents-react/components/navigation-item/navigation-item'
|
|
||||||
).then((mod) => ({ default: mod.ObcNavigationItem })),
|
|
||||||
)
|
|
||||||
|
|
||||||
export interface TokenUsageDashboardProps {
|
export interface TokenUsageDashboardProps {
|
||||||
|
/** 从 API 获取的使用量数据 */
|
||||||
data: {
|
data: {
|
||||||
opusModels: ModelUsage[]
|
opusModels: ModelUsage[]
|
||||||
fetchedAt: string
|
fetchedAt: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 告警阈值 */
|
/** 告警信息类型 */
|
||||||
const ALERT_THRESHOLD = 0.2 // 20% 警戒线
|
|
||||||
const CRITICAL_THRESHOLD = 0.05 // 5% 紧急阈值
|
|
||||||
|
|
||||||
interface AlertInfo {
|
interface AlertInfo {
|
||||||
account: string
|
account: string
|
||||||
model: string
|
model: string
|
||||||
@@ -68,18 +42,34 @@ interface AlertInfo {
|
|||||||
type: AlertType
|
type: AlertType
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 常量配置
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** 告警阈值配置 */
|
||||||
|
const ALERT_THRESHOLD = 0.2 // 20% - 警告阈值
|
||||||
|
const CRITICAL_THRESHOLD = 0.05 // 5% - 紧急阈值
|
||||||
|
|
||||||
|
/** 已知的账户前缀列表 */
|
||||||
|
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 => {
|
const extractUsername = (account: string): string => {
|
||||||
// 移除 "antigravity-" 前缀
|
for (const prefix of KNOWN_PREFIXES) {
|
||||||
if (account.startsWith('antigravity-')) {
|
|
||||||
return account.slice('antigravity-'.length)
|
|
||||||
}
|
|
||||||
// 移除其他常见前缀
|
|
||||||
const prefixes = ['anthropic-', 'claude-', 'openai-']
|
|
||||||
for (const prefix of prefixes) {
|
|
||||||
if (account.startsWith(prefix)) {
|
if (account.startsWith(prefix)) {
|
||||||
return account.slice(prefix.length)
|
return account.slice(prefix.length)
|
||||||
}
|
}
|
||||||
@@ -89,11 +79,17 @@ const extractUsername = (account: string): string => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 计算告警列表
|
* 计算告警列表
|
||||||
|
*
|
||||||
|
* 根据配额剩余比例生成告警列表,并按严重程度排序。
|
||||||
|
*
|
||||||
|
* @param models - 模型使用量列表
|
||||||
|
* @returns 排序后的告警列表 (Alarm 优先)
|
||||||
*/
|
*/
|
||||||
const getAlerts = (models: ModelUsage[]): AlertInfo[] => {
|
const computeAlerts = (models: ModelUsage[]): AlertInfo[] => {
|
||||||
const alerts: AlertInfo[] = []
|
const alerts: AlertInfo[] = []
|
||||||
|
|
||||||
for (const model of models) {
|
for (const model of models) {
|
||||||
|
// 低于 5% 为紧急告警
|
||||||
if (model.remainingFraction < CRITICAL_THRESHOLD) {
|
if (model.remainingFraction < CRITICAL_THRESHOLD) {
|
||||||
alerts.push({
|
alerts.push({
|
||||||
account: model.account,
|
account: model.account,
|
||||||
@@ -102,7 +98,9 @@ const getAlerts = (models: ModelUsage[]): AlertInfo[] => {
|
|||||||
remainingFraction: model.remainingFraction,
|
remainingFraction: model.remainingFraction,
|
||||||
type: AlertType.Alarm,
|
type: AlertType.Alarm,
|
||||||
})
|
})
|
||||||
} else if (model.remainingFraction < ALERT_THRESHOLD) {
|
}
|
||||||
|
// 低于 20% 为警告
|
||||||
|
else if (model.remainingFraction < ALERT_THRESHOLD) {
|
||||||
alerts.push({
|
alerts.push({
|
||||||
account: model.account,
|
account: model.account,
|
||||||
model: model.model,
|
model: model.model,
|
||||||
@@ -113,16 +111,20 @@ const getAlerts = (models: ModelUsage[]): AlertInfo[] => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按严重程度排序(Alarm 优先)
|
// 按严重程度排序: Alarm > Warning,相同级别按剩余配额升序
|
||||||
return alerts.sort((a, b) => {
|
return alerts.sort((a, b) => {
|
||||||
if (a.type === AlertType.Alarm && b.type !== AlertType.Alarm) return -1
|
if (a.type !== b.type) {
|
||||||
if (a.type !== AlertType.Alarm && b.type === AlertType.Alarm) return 1
|
return a.type === AlertType.Alarm ? -1 : 1
|
||||||
|
}
|
||||||
return a.remainingFraction - b.remainingFraction
|
return a.remainingFraction - b.remainingFraction
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取最高告警类型
|
* 获取最高级别的告警类型
|
||||||
|
*
|
||||||
|
* @param alerts - 告警列表
|
||||||
|
* @returns 最高级别的告警类型
|
||||||
*/
|
*/
|
||||||
const getHighestAlertType = (alerts: AlertInfo[]): AlertType => {
|
const getHighestAlertType = (alerts: AlertInfo[]): AlertType => {
|
||||||
if (alerts.some((a) => a.type === AlertType.Alarm)) return AlertType.Alarm
|
if (alerts.some((a) => a.type === AlertType.Alarm)) return AlertType.Alarm
|
||||||
@@ -130,151 +132,77 @@ const getHighestAlertType = (alerts: AlertInfo[]): AlertType => {
|
|||||||
return AlertType.Caution
|
return AlertType.Caution
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ============================================================================
|
||||||
* TopBar 占位符(SSR 时显示)
|
// 主组件
|
||||||
*/
|
// ============================================================================
|
||||||
const TopBarFallback = () => (
|
|
||||||
<div className="h-14 bg-[var(--container-background-color)] border-b border-[var(--divider-color)]" />
|
|
||||||
)
|
|
||||||
|
|
||||||
export const TokenUsageDashboard = ({ data }: TokenUsageDashboardProps) => {
|
export const TokenUsageDashboard = ({ data }: TokenUsageDashboardProps) => {
|
||||||
const { opusModels } = data
|
const { opusModels } = data
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
|
|
||||||
|
// UI 状态
|
||||||
const [alertMuted, setAlertMuted] = useState(false)
|
const [alertMuted, setAlertMuted] = useState(false)
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
|
||||||
// 计算告警
|
// 计算告警信息 (使用 useMemo 缓存)
|
||||||
const alerts = useMemo(() => getAlerts(opusModels), [opusModels])
|
const alerts = useMemo(() => computeAlerts(opusModels), [opusModels])
|
||||||
const alertType = getHighestAlertType(alerts)
|
const alertType = useMemo(() => getHighestAlertType(alerts), [alerts])
|
||||||
|
|
||||||
// 处理静音点击
|
// 获取最重要的告警 (用于顶栏显示)
|
||||||
|
const topAlert = alerts[0]
|
||||||
|
|
||||||
|
// ========== 事件处理器 ==========
|
||||||
|
|
||||||
|
/** 切换告警静音状态 */
|
||||||
const handleMuteClick = useCallback(() => {
|
const handleMuteClick = useCallback(() => {
|
||||||
setAlertMuted((prev) => !prev)
|
setAlertMuted((prev) => !prev)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 处理菜单按钮点击
|
/** 切换菜单开关状态 */
|
||||||
const handleMenuButtonClick = useCallback(() => {
|
const handleMenuButtonClick = useCallback(() => {
|
||||||
setMenuOpen((prev) => !prev)
|
setMenuOpen((prev) => !prev)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 处理主题切换
|
// ========== 渲染 ==========
|
||||||
const handleThemeChange = useCallback(
|
|
||||||
(newTheme: 'day' | 'night') => {
|
|
||||||
setTheme(newTheme)
|
|
||||||
setMenuOpen(false)
|
|
||||||
},
|
|
||||||
[setTheme],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col bg-[var(--container-background-color)] text-[var(--on-container-color)]">
|
<div className="min-h-screen flex flex-col bg-[var(--container-background-color,#f7f7f7)] text-[var(--element-active-color,#3d3d3d)]">
|
||||||
{/* 顶部导航栏 */}
|
{/* 顶部导航栏 */}
|
||||||
<header className="sticky top-0 z-50">
|
<TopBar
|
||||||
<Suspense fallback={<TopBarFallback />}>
|
|
||||||
<ObcTopBar
|
|
||||||
appTitle="Token Usage Viewer"
|
appTitle="Token Usage Viewer"
|
||||||
pageName=""
|
|
||||||
onMenuButtonClicked={
|
|
||||||
handleMenuButtonClick as unknown as EventListener
|
|
||||||
}
|
|
||||||
menuButtonActivated={menuOpen}
|
menuButtonActivated={menuOpen}
|
||||||
>
|
onMenuButtonClick={handleMenuButtonClick}
|
||||||
{/* 右侧: 告警面板 */}
|
rightSlot={
|
||||||
<div slot="alerts">
|
<>
|
||||||
<Suspense fallback={null}>
|
<AlertBadge
|
||||||
<ObcAlertTopbarElement
|
count={alerts.length}
|
||||||
nAlerts={alerts.length}
|
|
||||||
alertType={alertType}
|
alertType={alertType}
|
||||||
alertMuted={alertMuted}
|
muted={alertMuted}
|
||||||
showAck={false}
|
onMuteClick={handleMuteClick}
|
||||||
onMuteclick={handleMuteClick as unknown as EventListener}
|
/>
|
||||||
>
|
{topAlert && (
|
||||||
{alerts.length > 0 && alerts[0] && (
|
<AlertNotification
|
||||||
<Suspense fallback={null}>
|
account={extractUsername(topAlert.account)}
|
||||||
<ObcNotificationMessageItem time="">
|
remainingPercent={Math.round(topAlert.remainingFraction * 100)}
|
||||||
<span slot="icon">
|
alertType={topAlert.type}
|
||||||
<Suspense fallback={null}>
|
/>
|
||||||
<ObcAlertIcon
|
)}
|
||||||
name={
|
</>
|
||||||
alerts[0].type === AlertType.Alarm
|
|
||||||
? 'alarm-unack'
|
|
||||||
: 'warning-unack'
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
|
||||||
</span>
|
|
||||||
<span slot="message">
|
|
||||||
{extractUsername(alerts[0].account)}: 剩余{' '}
|
|
||||||
{Math.round(alerts[0].remainingFraction * 100)}%
|
|
||||||
</span>
|
|
||||||
</ObcNotificationMessageItem>
|
|
||||||
</Suspense>
|
|
||||||
)}
|
|
||||||
</ObcAlertTopbarElement>
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
</ObcTopBar>
|
|
||||||
</Suspense>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* 侧边导航菜单 */}
|
{/* 主题切换侧边栏 */}
|
||||||
{menuOpen && (
|
<ThemeSidebar
|
||||||
<aside className="fixed top-14 left-0 z-40 h-[calc(100vh-3.5rem)] w-64 bg-[var(--container-background-color)] border-r border-[var(--divider-color)] shadow-lg">
|
open={menuOpen}
|
||||||
<Suspense fallback={null}>
|
onOpenChange={setMenuOpen}
|
||||||
<ObcNavigationMenu>
|
currentTheme={theme}
|
||||||
<div slot="main">
|
onThemeChange={setTheme}
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ObcNavigationItem
|
|
||||||
label="白天模式"
|
|
||||||
checked={theme === 'day'}
|
|
||||||
onClick={() => handleThemeChange('day')}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
slot="icon"
|
|
||||||
// biome-ignore lint: custom element
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: '<obi-palette-day></obi-palette-day>',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</ObcNavigationItem>
|
|
||||||
</Suspense>
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ObcNavigationItem
|
|
||||||
label="夜间模式"
|
|
||||||
checked={theme === 'night'}
|
|
||||||
onClick={() => handleThemeChange('night')}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
slot="icon"
|
|
||||||
// biome-ignore lint: custom element
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: '<obi-palette-night></obi-palette-night>',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ObcNavigationItem>
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
</ObcNavigationMenu>
|
|
||||||
</Suspense>
|
|
||||||
</aside>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 点击遮罩关闭菜单 */}
|
{/* 主内容区 - 配额圆环展示 */}
|
||||||
{menuOpen && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="关闭菜单"
|
|
||||||
className="fixed inset-0 z-30 bg-black/20 cursor-default"
|
|
||||||
onClick={() => setMenuOpen(false)}
|
|
||||||
onKeyDown={(e) => e.key === 'Escape' && setMenuOpen(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 主内容区 */}
|
|
||||||
<main className="flex-1 flex flex-col items-center justify-center p-8">
|
<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">
|
<div className="flex flex-wrap justify-center gap-10 lg:gap-16">
|
||||||
{opusModels.slice(0, 4).map((model) => (
|
{opusModels.map((model) => (
|
||||||
<HealthRing
|
<HealthRing
|
||||||
key={`${model.account}-${model.model}`}
|
key={`${model.account}-${model.model}`}
|
||||||
account={extractUsername(model.account)}
|
account={extractUsername(model.account)}
|
||||||
|
|||||||
140
src/components/ui/AlertBadge.tsx
Normal file
140
src/components/ui/AlertBadge.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* AlertBadge 组件
|
||||||
|
*
|
||||||
|
* 告警徽章组件,显示告警数量和状态。
|
||||||
|
* 支持三种告警类型:Alarm(紧急)、Warning(警告)、Caution(注意)
|
||||||
|
*/
|
||||||
|
import { AlertTriangle, Bell, BellOff, XCircle } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/** 告警类型枚举 */
|
||||||
|
export enum AlertType {
|
||||||
|
Caution = 'caution',
|
||||||
|
Warning = 'warning',
|
||||||
|
Alarm = 'alarm',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertBadgeProps {
|
||||||
|
/** 告警数量 */
|
||||||
|
count: number
|
||||||
|
/** 告警类型 */
|
||||||
|
alertType: AlertType
|
||||||
|
/** 是否静音 */
|
||||||
|
muted?: boolean
|
||||||
|
/** 静音按钮点击事件 */
|
||||||
|
onMuteClick?: () => void
|
||||||
|
/** 额外的 className */
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 告警类型对应的颜色配置 - 使用 OpenBridge CSS 变量 */
|
||||||
|
const ALERT_COLORS: Record<
|
||||||
|
AlertType,
|
||||||
|
{ bgVar: string; textClass: string; icon: typeof AlertTriangle }
|
||||||
|
> = {
|
||||||
|
[AlertType.Alarm]: {
|
||||||
|
bgVar: 'var(--alert-alarm-color, rgb(227, 0, 25))',
|
||||||
|
textClass: 'text-white',
|
||||||
|
icon: XCircle,
|
||||||
|
},
|
||||||
|
[AlertType.Warning]: {
|
||||||
|
bgVar: 'var(--alert-warning-color, rgb(254, 148, 19))',
|
||||||
|
textClass: 'text-white',
|
||||||
|
icon: AlertTriangle,
|
||||||
|
},
|
||||||
|
[AlertType.Caution]: {
|
||||||
|
bgVar: 'var(--alert-caution-color, rgb(255, 219, 55))',
|
||||||
|
textClass: 'text-gray-900',
|
||||||
|
icon: AlertTriangle,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AlertBadge = ({
|
||||||
|
count,
|
||||||
|
alertType,
|
||||||
|
muted = false,
|
||||||
|
onMuteClick,
|
||||||
|
className,
|
||||||
|
}: AlertBadgeProps) => {
|
||||||
|
const { bgVar, textClass, icon: Icon } = ALERT_COLORS[alertType]
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center gap-1', className)}>
|
||||||
|
{/* 告警徽章 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 px-2.5 py-1 rounded-full',
|
||||||
|
textClass,
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: bgVar }}
|
||||||
|
>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
<span className="text-sm font-medium">{count}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 静音按钮 */}
|
||||||
|
{onMuteClick && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onMuteClick}
|
||||||
|
className={cn(
|
||||||
|
'hover:bg-[var(--element-hover-color,rgba(0,0,0,0.12))]',
|
||||||
|
muted && 'opacity-50',
|
||||||
|
)}
|
||||||
|
aria-label={muted ? '取消静音' : '静音告警'}
|
||||||
|
>
|
||||||
|
{muted ? (
|
||||||
|
<BellOff className="size-4 text-[var(--element-inactive-color,#707070)]" />
|
||||||
|
) : (
|
||||||
|
<Bell className="size-4 text-[var(--element-active-color,#3d3d3d)]" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertNotificationProps {
|
||||||
|
/** 账户名 */
|
||||||
|
account: string
|
||||||
|
/** 剩余百分比 */
|
||||||
|
remainingPercent: number
|
||||||
|
/** 告警类型 */
|
||||||
|
alertType: AlertType
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取告警类型对应的 CSS 变量颜色 */
|
||||||
|
const getAlertColor = (alertType: AlertType): string => {
|
||||||
|
switch (alertType) {
|
||||||
|
case AlertType.Alarm:
|
||||||
|
return 'var(--alert-alarm-color, rgb(227, 0, 25))'
|
||||||
|
case AlertType.Warning:
|
||||||
|
return 'var(--alert-warning-color, rgb(254, 148, 19))'
|
||||||
|
case AlertType.Caution:
|
||||||
|
return 'var(--alert-caution-color, rgb(255, 219, 55))'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AlertNotification = ({
|
||||||
|
account,
|
||||||
|
remainingPercent,
|
||||||
|
alertType,
|
||||||
|
}: AlertNotificationProps) => {
|
||||||
|
const { icon: Icon } = ALERT_COLORS[alertType]
|
||||||
|
const alertColor = getAlertColor(alertType)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 text-sm">
|
||||||
|
<Icon className="size-4 shrink-0" style={{ color: alertColor }} />
|
||||||
|
<span className="text-[var(--element-active-color,#3d3d3d)]">
|
||||||
|
{account}: 剩余 {remainingPercent}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
130
src/components/ui/ThemeSidebar.tsx
Normal file
130
src/components/ui/ThemeSidebar.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* ThemeSidebar 组件
|
||||||
|
*
|
||||||
|
* 主题切换侧边栏,使用 shadcn Sheet 组件实现。
|
||||||
|
* 支持 OpenBridge 四种主题:day/bright/dusk/night
|
||||||
|
*/
|
||||||
|
import { Check, Moon, Sun, SunDim, Sunrise } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from '@/components/ui/sheet'
|
||||||
|
import type { ObcTheme } from '@/hooks/useTheme'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface ThemeSidebarProps {
|
||||||
|
/** 是否打开 */
|
||||||
|
open: boolean
|
||||||
|
/** 关闭事件 */
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
/** 当前主题 */
|
||||||
|
currentTheme: ObcTheme
|
||||||
|
/** 主题切换事件 */
|
||||||
|
onThemeChange: (theme: ObcTheme) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主题配置 */
|
||||||
|
const THEME_OPTIONS: Array<{
|
||||||
|
value: ObcTheme
|
||||||
|
label: string
|
||||||
|
icon: typeof Sun
|
||||||
|
description: string
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
value: 'day',
|
||||||
|
label: '白天模式',
|
||||||
|
icon: Sun,
|
||||||
|
description: '明亮的白色背景',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'bright',
|
||||||
|
label: '明亮模式',
|
||||||
|
icon: SunDim,
|
||||||
|
description: '高对比度明亮主题',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'dusk',
|
||||||
|
label: '黄昏模式',
|
||||||
|
icon: Sunrise,
|
||||||
|
description: '柔和的暖色调',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'night',
|
||||||
|
label: '夜间模式',
|
||||||
|
icon: Moon,
|
||||||
|
description: '深色背景护眼',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const ThemeSidebar = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
currentTheme,
|
||||||
|
onThemeChange,
|
||||||
|
}: ThemeSidebarProps) => {
|
||||||
|
const handleThemeSelect = (theme: ObcTheme) => {
|
||||||
|
onThemeChange(theme)
|
||||||
|
onOpenChange(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent
|
||||||
|
side="left"
|
||||||
|
className="w-72 bg-[var(--container-background-color,#fcfcfc)] border-[var(--divider-color,#e0e0e0)]"
|
||||||
|
showCloseButton={false}
|
||||||
|
>
|
||||||
|
<SheetHeader className="border-b border-[var(--divider-color,#e0e0e0)] pb-4">
|
||||||
|
<SheetTitle className="text-[var(--on-container-color,#1a1a1a)]">
|
||||||
|
外观设置
|
||||||
|
</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<nav className="flex flex-col gap-1 py-4">
|
||||||
|
{THEME_OPTIONS.map(({ value, label, icon: Icon, description }) => {
|
||||||
|
const isActive = currentTheme === value
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={value}
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleThemeSelect(value)}
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start gap-3 h-14 px-3',
|
||||||
|
'hover:bg-[var(--element-hover-color,rgba(0,0,0,0.12))]',
|
||||||
|
isActive &&
|
||||||
|
'bg-[var(--element-focused-color,rgba(0,0,0,0.08))]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'size-10 rounded-lg flex items-center justify-center',
|
||||||
|
'bg-[var(--container-section-color,#f0f0f0)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-5 text-[var(--element-active-color,#1a1a1a)]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 text-left">
|
||||||
|
<div className="text-sm font-medium text-[var(--element-active-color,#1a1a1a)]">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--element-inactive-color,#666)]">
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isActive && (
|
||||||
|
<Check className="size-5 text-[var(--alert-running-color,#34C759)]" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
84
src/components/ui/TopBar.tsx
Normal file
84
src/components/ui/TopBar.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* TopBar 组件
|
||||||
|
*
|
||||||
|
* 基于 OpenBridge 设计系统的顶部导航栏,使用 shadcn/ui 重新实现。
|
||||||
|
* 特性:汉堡菜单按钮、应用标题、右侧告警/操作区域
|
||||||
|
*/
|
||||||
|
import { Menu } from 'lucide-react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface TopBarProps {
|
||||||
|
/** 应用标题 */
|
||||||
|
appTitle: string
|
||||||
|
/** 页面名称(可选,显示在标题右侧) */
|
||||||
|
pageName?: string
|
||||||
|
/** 菜单按钮是否激活 */
|
||||||
|
menuButtonActivated?: boolean
|
||||||
|
/** 菜单按钮点击事件 */
|
||||||
|
onMenuButtonClick?: () => void
|
||||||
|
/** 右侧插槽内容(告警、操作按钮等) */
|
||||||
|
rightSlot?: ReactNode
|
||||||
|
/** 额外的 className */
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TopBar = ({
|
||||||
|
appTitle,
|
||||||
|
pageName,
|
||||||
|
menuButtonActivated = false,
|
||||||
|
onMenuButtonClick,
|
||||||
|
rightSlot,
|
||||||
|
className,
|
||||||
|
}: TopBarProps) => {
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className={cn(
|
||||||
|
'h-12 flex items-center justify-between px-2',
|
||||||
|
'bg-[var(--container-background-color,#fcfcfc)]',
|
||||||
|
'border-b border-[var(--divider-color,#e0e0e0)]',
|
||||||
|
'shadow-[0px_2px_4px_0px_rgba(0,0,0,0.1)]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* 左侧:汉堡菜单按钮 + 标题 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onMenuButtonClick}
|
||||||
|
className={cn(
|
||||||
|
'size-8 rounded-md',
|
||||||
|
'hover:bg-[var(--element-focused-color,rgba(0,0,0,0.08))]',
|
||||||
|
menuButtonActivated &&
|
||||||
|
'bg-[var(--element-active-color,rgba(0,0,0,0.12))]',
|
||||||
|
)}
|
||||||
|
aria-label="打开菜单"
|
||||||
|
aria-expanded={menuButtonActivated}
|
||||||
|
>
|
||||||
|
<Menu className="size-5 text-[var(--on-container-color,#1a1a1a)]" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-base font-medium text-[var(--on-container-color,#1a1a1a)]">
|
||||||
|
{appTitle}
|
||||||
|
</span>
|
||||||
|
{pageName && (
|
||||||
|
<>
|
||||||
|
<span className="text-[var(--on-container-low-color,#666)]">
|
||||||
|
/
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-[var(--on-container-low-color,#666)]">
|
||||||
|
{pageName}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧:告警/操作区域 */}
|
||||||
|
{rightSlot && <div className="flex items-center gap-1">{rightSlot}</div>}
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
64
src/components/ui/button.tsx
Normal file
64
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { Slot } from '@radix-ui/react-slot'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import type * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||||
|
outline:
|
||||||
|
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||||
|
secondary:
|
||||||
|
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
ghost:
|
||||||
|
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||||
|
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
||||||
|
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||||
|
icon: 'size-9',
|
||||||
|
'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
'icon-sm': 'size-8',
|
||||||
|
'icon-lg': 'size-10',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = 'default',
|
||||||
|
size = 'default',
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'button'> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : 'button'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
141
src/components/ui/sheet.tsx
Normal file
141
src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import * as SheetPrimitive from '@radix-ui/react-dialog'
|
||||||
|
import { XIcon } from 'lucide-react'
|
||||||
|
import type * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||||
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||||
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||||
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||||
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
className={cn(
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = 'right',
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
|
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
data-slot="sheet-content"
|
||||||
|
className={cn(
|
||||||
|
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||||
|
side === 'right' &&
|
||||||
|
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
|
||||||
|
side === 'left' &&
|
||||||
|
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
|
||||||
|
side === 'top' &&
|
||||||
|
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
|
||||||
|
side === 'bottom' &&
|
||||||
|
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
className={cn('flex flex-col gap-1.5 p-4', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn('text-foreground font-semibold', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn('text-muted-foreground text-sm', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* 数据库连接模块
|
* 数据库连接模块
|
||||||
*
|
*
|
||||||
* 使用 Bun 内置的 SQLite 驱动,无需额外安装原生模块。
|
* 使用 Bun 内置的 SQLite 驱动 (bun:sqlite),无需安装额外的原生模块。
|
||||||
* 数据库文件存储在可执行文件同级的 data/app.db
|
* 数据库文件存储在可执行文件同目录的 data/app.db。
|
||||||
|
*
|
||||||
|
* 特性:
|
||||||
|
* - WAL 模式提升并发性能
|
||||||
|
* - 自动创建表结构
|
||||||
|
* - 智能路径检测 (开发/生产)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Database } from 'bun:sqlite'
|
import { Database } from 'bun:sqlite'
|
||||||
@@ -11,16 +16,31 @@ import { dirname, join } from 'node:path'
|
|||||||
import { drizzle } from 'drizzle-orm/bun-sqlite'
|
import { drizzle } from 'drizzle-orm/bun-sqlite'
|
||||||
import * as schema from '@/db/schema'
|
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')
|
const dataDir = join(baseDir, 'data')
|
||||||
|
|
||||||
// 确保 data 目录存在
|
// 确保 data 目录存在
|
||||||
@@ -31,14 +51,24 @@ function getDbPath(): string {
|
|||||||
return join(dataDir, 'app.db')
|
return join(dataDir, 'app.db')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 数据库文件路径 */
|
// ============================================================================
|
||||||
|
// 表初始化
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** 数据库文件路径 (在模块加载时计算一次) */
|
||||||
const DB_PATH = getDbPath()
|
const DB_PATH = getDbPath()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化数据库表结构
|
* 初始化数据库表结构
|
||||||
|
*
|
||||||
|
* 使用 IF NOT EXISTS 确保幂等性,可安全多次执行。
|
||||||
|
* 创建 usage_history 表和相应的索引。
|
||||||
|
*
|
||||||
|
* @param sqlite - SQLite 数据库实例
|
||||||
*/
|
*/
|
||||||
function initTables(sqlite: Database) {
|
const initTables = (sqlite: Database): void => {
|
||||||
sqlite.exec(`
|
sqlite.exec(`
|
||||||
|
-- 使用量历史记录表
|
||||||
CREATE TABLE IF NOT EXISTS usage_history (
|
CREATE TABLE IF NOT EXISTS usage_history (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
account TEXT NOT NULL,
|
account TEXT NOT NULL,
|
||||||
@@ -48,26 +78,38 @@ function initTables(sqlite: Database) {
|
|||||||
reset_time TEXT,
|
reset_time TEXT,
|
||||||
recorded_at INTEGER NOT NULL DEFAULT (unixepoch())
|
recorded_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- 按记录时间查询的索引
|
||||||
CREATE INDEX IF NOT EXISTS idx_usage_history_recorded_at
|
CREATE INDEX IF NOT EXISTS idx_usage_history_recorded_at
|
||||||
ON 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 })
|
const sqlite = new Database(DB_PATH, { create: true })
|
||||||
|
|
||||||
|
// 启用 WAL 模式,提升并发性能
|
||||||
sqlite.exec('PRAGMA journal_mode = WAL;')
|
sqlite.exec('PRAGMA journal_mode = WAL;')
|
||||||
|
|
||||||
// 自动初始化表结构
|
// 初始化表结构
|
||||||
initTables(sqlite)
|
initTables(sqlite)
|
||||||
|
|
||||||
return drizzle(sqlite, { schema })
|
return drizzle(sqlite, { schema })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 数据库实例类型 */
|
/** 数据库实例类型 (用于 TypeScript 类型推导) */
|
||||||
export type Db = ReturnType<typeof createDb>
|
export type Db = ReturnType<typeof createDb>
|
||||||
|
|||||||
160
src/env.ts
160
src/env.ts
@@ -1,10 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* 环境变量配置模块
|
||||||
|
*
|
||||||
|
* 使用 @t3-oss/env-core 进行类型安全的环境变量验证。
|
||||||
|
* 支持从同目录的 .env 文件加载配置(优先级低于系统环境变量)。
|
||||||
|
*/
|
||||||
import { existsSync, readFileSync } from 'node:fs'
|
import { existsSync, readFileSync } from 'node:fs'
|
||||||
import { dirname, join } from 'node:path'
|
import { dirname, join } from 'node:path'
|
||||||
import { createEnv } from '@t3-oss/env-core'
|
import { createEnv } from '@t3-oss/env-core'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
/** 默认的 TOKEN_USAGE_URL */
|
/** Token 使用量 API 的默认地址 (按模型分组) */
|
||||||
const DEFAULT_TOKEN_USAGE_URL = 'http://10.0.1.1:8318/usage'
|
const DEFAULT_TOKEN_USAGE_URL = 'http://10.0.1.1:8318/api/usage/model'
|
||||||
|
|
||||||
|
/** 服务器端口默认值 */
|
||||||
|
const DEFAULT_SERVER_PORT = '13098'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断当前是否为打包后的可执行文件运行环境
|
||||||
|
*
|
||||||
|
* @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 配置文件读取环境变量
|
* 从同目录的 .env 配置文件读取环境变量
|
||||||
@@ -13,71 +77,69 @@ const DEFAULT_TOKEN_USAGE_URL = 'http://10.0.1.1:8318/usage'
|
|||||||
* 1. 系统环境变量 (process.env)
|
* 1. 系统环境变量 (process.env)
|
||||||
* 2. 可执行文件同目录的 .env 文件
|
* 2. 可执行文件同目录的 .env 文件
|
||||||
* 3. 默认值
|
* 3. 默认值
|
||||||
|
*
|
||||||
|
* @returns 从文件解析的环境变量
|
||||||
*/
|
*/
|
||||||
function loadEnvFromFile(): Record<string, string> {
|
const loadEnvFromFile = (): Record<string, string> => {
|
||||||
const result: Record<string, string> = {}
|
const envPath = join(getBaseDir(), '.env')
|
||||||
|
|
||||||
// 确定可执行文件所在目录
|
if (!existsSync(envPath)) return {}
|
||||||
const execPath = process.execPath
|
|
||||||
const isBundled = !execPath.includes('node') && !execPath.includes('bun')
|
|
||||||
const baseDir = isBundled ? dirname(execPath) : process.cwd()
|
|
||||||
const envPath = join(baseDir, '.env')
|
|
||||||
|
|
||||||
// 如果 .env 文件存在,解析它
|
|
||||||
if (existsSync(envPath)) {
|
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(envPath, 'utf-8')
|
const content = readFileSync(envPath, 'utf-8')
|
||||||
for (const line of content.split('\n')) {
|
return parseEnvContent(content)
|
||||||
const trimmed = line.trim()
|
|
||||||
// 跳过空行和注释
|
|
||||||
if (!trimmed || trimmed.startsWith('#')) continue
|
|
||||||
|
|
||||||
const eqIndex = trimmed.indexOf('=')
|
|
||||||
if (eqIndex > 0) {
|
|
||||||
const key = trimmed.slice(0, eqIndex).trim()
|
|
||||||
const value = trimmed.slice(eqIndex + 1).trim()
|
|
||||||
// 只设置不存在的环境变量
|
|
||||||
if (!process.env[key]) {
|
|
||||||
result[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// 忽略读取错误
|
// 忽略读取错误(权限问题等)
|
||||||
}
|
return {}
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载配置文件中的环境变量
|
|
||||||
const fileEnv = loadEnvFromFile()
|
|
||||||
|
|
||||||
// 合并环境变量: process.env > fileEnv > 默认值
|
|
||||||
const mergedEnv: Record<string, string | undefined> = {
|
|
||||||
...process.env,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从文件填充缺失的变量
|
|
||||||
for (const [key, value] of Object.entries(fileEnv)) {
|
|
||||||
if (!mergedEnv[key]) {
|
|
||||||
mergedEnv[key] = value
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果仍然没有 TOKEN_USAGE_URL,使用默认值
|
/**
|
||||||
if (!mergedEnv.TOKEN_USAGE_URL) {
|
* 构建合并后的环境变量对象
|
||||||
mergedEnv.TOKEN_USAGE_URL = DEFAULT_TOKEN_USAGE_URL
|
*
|
||||||
|
* 合并顺序: 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
|
||||||
|
merged.PROJECT_SERVER_PORT ??= DEFAULT_SERVER_PORT
|
||||||
|
|
||||||
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类型安全的环境变量配置
|
||||||
|
*
|
||||||
|
* 服务端变量:
|
||||||
|
* - TOKEN_USAGE_URL: Token 使用量 API 地址
|
||||||
|
*
|
||||||
|
* 客户端变量 (VITE_ 前缀):
|
||||||
|
* - VITE_APP_TITLE: 应用标题 (可选)
|
||||||
|
*/
|
||||||
export const env = createEnv({
|
export const env = createEnv({
|
||||||
server: {
|
server: {
|
||||||
TOKEN_USAGE_URL: z.string().url(),
|
TOKEN_USAGE_URL: z.string().url(),
|
||||||
|
PROJECT_SERVER_PORT: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(65535)
|
||||||
|
.default(13098),
|
||||||
},
|
},
|
||||||
clientPrefix: 'VITE_',
|
clientPrefix: 'VITE_',
|
||||||
client: {
|
client: {
|
||||||
VITE_APP_TITLE: z.string().min(1).optional(),
|
VITE_APP_TITLE: z.string().min(1).optional(),
|
||||||
},
|
},
|
||||||
runtimeEnv: mergedEnv,
|
runtimeEnv: buildMergedEnv(),
|
||||||
emptyStringAsUndefined: true,
|
emptyStringAsUndefined: true,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* 倒计时 Hook
|
* 倒计时 Hook
|
||||||
*
|
*
|
||||||
* 计算目标时间到当前时间的剩余时间,每秒更新一次
|
* 计算目标时间到当前时间的剩余时间,每秒更新一次。
|
||||||
|
* 使用 useMemo 优化计算,避免不必要的重渲染。
|
||||||
*/
|
*/
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
/** 倒计时结果类型 */
|
||||||
export interface CountdownResult {
|
export interface CountdownResult {
|
||||||
/** 剩余总秒数 */
|
/** 剩余总秒数 */
|
||||||
totalSeconds: number
|
totalSeconds: number
|
||||||
@@ -20,58 +22,84 @@ export interface CountdownResult {
|
|||||||
isExpired: boolean
|
isExpired: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 时间常量 (毫秒) */
|
||||||
* 计算并实时更新目标时间的倒计时
|
const SECOND = 1000
|
||||||
* @param targetTime ISO 8601 格式的目标时间字符串
|
const MINUTE = 60
|
||||||
* @returns 倒计时结果
|
const HOUR = 3600
|
||||||
*/
|
|
||||||
export const useCountdown = (
|
|
||||||
targetTime: string | undefined,
|
|
||||||
): CountdownResult => {
|
|
||||||
const [now, setNow] = useState(() => Date.now())
|
|
||||||
|
|
||||||
useEffect(() => {
|
/** 无效/过期时的默认返回值 */
|
||||||
const timer = setInterval(() => {
|
const EXPIRED_RESULT: CountdownResult = {
|
||||||
setNow(Date.now())
|
|
||||||
}, 1000)
|
|
||||||
|
|
||||||
return () => clearInterval(timer)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
if (!targetTime) {
|
|
||||||
return {
|
|
||||||
totalSeconds: 0,
|
totalSeconds: 0,
|
||||||
hours: 0,
|
hours: 0,
|
||||||
minutes: 0,
|
minutes: 0,
|
||||||
seconds: 0,
|
seconds: 0,
|
||||||
formatted: '--:--',
|
formatted: '--:--',
|
||||||
isExpired: true,
|
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 target = new Date(targetTime).getTime()
|
||||||
const diff = Math.max(0, target - now)
|
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)
|
if (totalSeconds === 0) return EXPIRED_RESULT
|
||||||
const seconds = totalSeconds % 60
|
|
||||||
|
|
||||||
let formatted: string
|
const hours = Math.floor(totalSeconds / HOUR)
|
||||||
if (hours > 0) {
|
const minutes = Math.floor((totalSeconds % HOUR) / MINUTE)
|
||||||
formatted = `${hours}h ${minutes}m`
|
const seconds = totalSeconds % MINUTE
|
||||||
} else if (minutes > 0) {
|
|
||||||
formatted = `${minutes}m ${seconds}s`
|
|
||||||
} else {
|
|
||||||
formatted = `${seconds}s`
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalSeconds,
|
totalSeconds,
|
||||||
hours,
|
hours,
|
||||||
minutes,
|
minutes,
|
||||||
seconds,
|
seconds,
|
||||||
formatted,
|
formatted: formatCountdown(hours, minutes, seconds),
|
||||||
isExpired: totalSeconds === 0,
|
isExpired: false,
|
||||||
}
|
}
|
||||||
|
}, [now, targetTime])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,70 @@
|
|||||||
/**
|
/**
|
||||||
* OpenBridge 主题管理 Hook
|
* OpenBridge 主题管理 Hook
|
||||||
*
|
*
|
||||||
* 管理 data-obc-theme 属性,支持 day/dusk/night/bright 四种主题
|
* 管理 data-obc-theme 属性,支持 day/dusk/night/bright 四种主题。
|
||||||
* 主题选择会持久化到 localStorage
|
* 主题选择会持久化到 localStorage。
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
/** 支持的 OpenBridge 主题类型 */
|
||||||
export type ObcTheme = 'day' | 'dusk' | 'night' | 'bright'
|
export type ObcTheme = 'day' | 'dusk' | 'night' | 'bright'
|
||||||
|
|
||||||
|
/** 主题列表,用于循环切换 */
|
||||||
|
const THEMES: readonly ObcTheme[] = ['day', 'dusk', 'night', 'bright'] as const
|
||||||
|
|
||||||
|
/** localStorage 存储键名 */
|
||||||
const STORAGE_KEY = 'obc-theme'
|
const STORAGE_KEY = 'obc-theme'
|
||||||
|
|
||||||
|
/** 默认主题 */
|
||||||
const DEFAULT_THEME: ObcTheme = 'day'
|
const DEFAULT_THEME: ObcTheme = 'day'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类型守卫:检查值是否为有效的主题
|
||||||
|
*
|
||||||
|
* @param value - 待检查的值
|
||||||
|
* @returns 是否为有效主题
|
||||||
|
*/
|
||||||
|
const isValidTheme = (value: unknown): value is ObcTheme =>
|
||||||
|
typeof value === 'string' && THEMES.includes(value as ObcTheme)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取初始主题(从 localStorage 或默认值)
|
* 获取初始主题(从 localStorage 或默认值)
|
||||||
|
*
|
||||||
|
* @returns 初始主题值
|
||||||
*/
|
*/
|
||||||
const getInitialTheme = (): ObcTheme => {
|
const getInitialTheme = (): ObcTheme => {
|
||||||
if (typeof window === 'undefined') {
|
// SSR 环境下使用默认主题
|
||||||
return DEFAULT_THEME
|
if (typeof window === 'undefined') return DEFAULT_THEME
|
||||||
}
|
|
||||||
const stored = localStorage.getItem(STORAGE_KEY)
|
const stored = localStorage.getItem(STORAGE_KEY)
|
||||||
if (stored && ['day', 'dusk', 'night', 'bright'].includes(stored)) {
|
return isValidTheme(stored) ? stored : DEFAULT_THEME
|
||||||
return stored as ObcTheme
|
|
||||||
}
|
|
||||||
return DEFAULT_THEME
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 管理 OpenBridge 主题切换
|
* 管理 OpenBridge 主题切换
|
||||||
|
*
|
||||||
|
* @returns 主题状态和控制函数
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const { theme, setTheme, cycleTheme } = useTheme()
|
||||||
|
*
|
||||||
|
* // 设置为夜间模式
|
||||||
|
* setTheme('night')
|
||||||
|
*
|
||||||
|
* // 循环切换到下一个主题
|
||||||
|
* cycleTheme()
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
export const useTheme = () => {
|
export const useTheme = () => {
|
||||||
const [theme, setThemeState] = useState<ObcTheme>(getInitialTheme)
|
const [theme, setThemeState] = useState<ObcTheme>(getInitialTheme)
|
||||||
|
|
||||||
// 应用主题到 DOM
|
// 应用主题到 DOM 并持久化
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof document !== 'undefined') {
|
if (typeof document === 'undefined') return
|
||||||
|
|
||||||
document.documentElement.setAttribute('data-obc-theme', theme)
|
document.documentElement.setAttribute('data-obc-theme', theme)
|
||||||
localStorage.setItem(STORAGE_KEY, theme)
|
localStorage.setItem(STORAGE_KEY, theme)
|
||||||
}
|
|
||||||
}, [theme])
|
}, [theme])
|
||||||
|
|
||||||
// 切换到指定主题
|
// 切换到指定主题
|
||||||
@@ -46,18 +74,12 @@ export const useTheme = () => {
|
|||||||
|
|
||||||
// 循环切换主题
|
// 循环切换主题
|
||||||
const cycleTheme = useCallback(() => {
|
const cycleTheme = useCallback(() => {
|
||||||
const themes: ObcTheme[] = ['day', 'dusk', 'night', 'bright']
|
setThemeState((current) => {
|
||||||
const currentIndex = themes.indexOf(theme)
|
const currentIndex = THEMES.indexOf(current)
|
||||||
const nextIndex = (currentIndex + 1) % themes.length
|
const nextIndex = (currentIndex + 1) % THEMES.length
|
||||||
const nextTheme = themes[nextIndex]
|
return THEMES[nextIndex] ?? DEFAULT_THEME
|
||||||
if (nextTheme) {
|
})
|
||||||
setThemeState(nextTheme)
|
}, [])
|
||||||
}
|
|
||||||
}, [theme])
|
|
||||||
|
|
||||||
return {
|
return { theme, setTheme, cycleTheme } as const
|
||||||
theme,
|
|
||||||
setTheme,
|
|
||||||
cycleTheme,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* ORPC 同构客户端
|
* ORPC 同构客户端
|
||||||
*
|
*
|
||||||
* 根据运行环境自动选择最优调用方式:
|
* 根据运行环境自动选择最优的 RPC 调用方式:
|
||||||
* - SSR (服务端): 直接调用 router,无 HTTP 开销
|
* - SSR (服务端): 直接调用 router 处理器,零网络开销
|
||||||
* - CSR (客户端): 通过 /api/rpc 端点 HTTP 调用
|
* - CSR (客户端): 通过 /api/rpc 端点进行 HTTP 调用
|
||||||
*
|
*
|
||||||
* 同时配置了 TanStack Query 集成,mutation 成功后自动刷新相关查询。
|
* 同时集成 TanStack Query,提供开箱即用的查询/突变 hooks。
|
||||||
*/
|
*/
|
||||||
import { createORPCClient } from '@orpc/client'
|
import { createORPCClient } from '@orpc/client'
|
||||||
import { RPCLink } from '@orpc/client/fetch'
|
import { RPCLink } from '@orpc/client/fetch'
|
||||||
@@ -16,36 +16,58 @@ import { getRequestHeaders } from '@tanstack/react-start/server'
|
|||||||
import { router } from './router'
|
import { router } from './router'
|
||||||
import type { RouterClient } from './types'
|
import type { RouterClient } from './types'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 客户端创建
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建同构 ORPC 客户端
|
* 创建同构 ORPC 客户端
|
||||||
*
|
*
|
||||||
* 服务端: 直接调用路由处理器
|
* 使用 TanStack Start 的 createIsomorphicFn 实现服务端/客户端代码分离:
|
||||||
* 客户端: 通过 HTTP 调用 /api/rpc 端点
|
* - server(): 在 SSR 时执行,直接调用路由处理器
|
||||||
|
* - client(): 在浏览器中执行,通过 HTTP 调用 API
|
||||||
*/
|
*/
|
||||||
const getORPCClient = createIsomorphicFn()
|
const getORPCClient = createIsomorphicFn()
|
||||||
.server(() =>
|
.server(() =>
|
||||||
|
// 服务端: 创建直接调用路由器的客户端
|
||||||
createRouterClient(router, {
|
createRouterClient(router, {
|
||||||
|
// 传递原始请求的 headers (用于身份验证等)
|
||||||
context: () => ({
|
context: () => ({
|
||||||
headers: getRequestHeaders(),
|
headers: getRequestHeaders(),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.client(() => {
|
.client(() => {
|
||||||
|
// 客户端: 创建 HTTP 客户端
|
||||||
const link = new RPCLink({
|
const link = new RPCLink({
|
||||||
url: `${window.location.origin}/api/rpc`,
|
url: `${window.location.origin}/api/rpc`,
|
||||||
})
|
})
|
||||||
return createORPCClient<RouterClient>(link)
|
return createORPCClient<RouterClient>(link)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 同构客户端实例 */
|
||||||
const client: RouterClient = getORPCClient()
|
const client: RouterClient = getORPCClient()
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TanStack Query 集成
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ORPC + TanStack Query 工具
|
* ORPC + TanStack Query 工具集
|
||||||
*
|
*
|
||||||
* 使用方式:
|
* 提供类型安全的 queryOptions 和 mutationOptions 方法。
|
||||||
|
*
|
||||||
|
* @example
|
||||||
* ```tsx
|
* ```tsx
|
||||||
* // 查询
|
* // 查询 (使用 Suspense)
|
||||||
* const { data } = useSuspenseQuery(orpc.usage.getUsage.queryOptions())
|
* 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)
|
export const orpc = createTanstackQueryUtils(client)
|
||||||
|
|||||||
@@ -8,17 +8,24 @@ import { env } from '@/env'
|
|||||||
import { dbProvider } from '@/orpc/middlewares'
|
import { dbProvider } from '@/orpc/middlewares'
|
||||||
import { os } from '@/orpc/server'
|
import { os } from '@/orpc/server'
|
||||||
|
|
||||||
/** 远程 API 响应中的模型数据结构 */
|
/** 远程 API 响应中的账户数据结构 */
|
||||||
interface RemoteModelData {
|
interface RemoteAccountData {
|
||||||
model: string
|
accountName: string
|
||||||
displayName?: string
|
|
||||||
remainingFraction: number
|
remainingFraction: number
|
||||||
resetTime?: string
|
resetTime?: string
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 远程 API 响应中的模型数据结构 (按模型分组) */
|
||||||
|
interface RemoteModelData {
|
||||||
|
modelId: string
|
||||||
|
displayName?: string
|
||||||
|
accounts: RemoteAccountData[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 远程 API 响应结构 */
|
/** 远程 API 响应结构 */
|
||||||
interface RemoteResponse {
|
interface RemoteResponse {
|
||||||
result: Record<string, RemoteModelData[]>
|
data: RemoteModelData[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getUsage = os.usage.getUsage
|
export const getUsage = os.usage.getUsage
|
||||||
@@ -31,7 +38,7 @@ export const getUsage = os.usage.getUsage
|
|||||||
}
|
}
|
||||||
const data = (await response.json()) as RemoteResponse
|
const data = (await response.json()) as RemoteResponse
|
||||||
|
|
||||||
// 2. 解析并筛选每个账户的 claude-opus-4-5-thinking 模型
|
// 2. 找到 claude-opus-4-5-thinking 模型,然后从其 accounts 中提取数据
|
||||||
const opusModels: Array<{
|
const opusModels: Array<{
|
||||||
account: string
|
account: string
|
||||||
model: string
|
model: string
|
||||||
@@ -40,21 +47,22 @@ export const getUsage = os.usage.getUsage
|
|||||||
resetTime?: string
|
resetTime?: string
|
||||||
}> = []
|
}> = []
|
||||||
|
|
||||||
for (const [accountFile, models] of Object.entries(data.result)) {
|
// 新 API 按模型分组,找到目标模型
|
||||||
const account = accountFile.replace('.json', '')
|
const opusModelData = data.data.find(
|
||||||
|
(m) => m.modelId === 'claude-opus-4-5-thinking',
|
||||||
// 只找 claude-opus-4-5-thinking 模型
|
|
||||||
const opusModel = models.find(
|
|
||||||
(m) => m.model === 'claude-opus-4-5-thinking',
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (opusModel) {
|
if (opusModelData) {
|
||||||
|
// 遍历该模型下的所有账户
|
||||||
|
for (const accountData of opusModelData.accounts) {
|
||||||
|
const account = accountData.accountName.replace('.json', '')
|
||||||
|
|
||||||
opusModels.push({
|
opusModels.push({
|
||||||
account,
|
account,
|
||||||
model: opusModel.model,
|
model: opusModelData.modelId,
|
||||||
displayName: opusModel.displayName,
|
displayName: opusModelData.displayName,
|
||||||
remainingFraction: opusModel.remainingFraction,
|
remainingFraction: accountData.remainingFraction,
|
||||||
resetTime: opusModel.resetTime,
|
resetTime: accountData.resetTime,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* 路由配置模块
|
||||||
|
*
|
||||||
|
* 创建并配置 TanStack Router 实例,集成 TanStack Query 进行 SSR 数据获取。
|
||||||
|
*
|
||||||
|
* 特性:
|
||||||
|
* - 自动滚动恢复
|
||||||
|
* - SSR 查询集成
|
||||||
|
* - 文件路由(由 routeTree.gen.ts 生成)
|
||||||
|
*/
|
||||||
import { QueryClient } from '@tanstack/react-query'
|
import { QueryClient } from '@tanstack/react-query'
|
||||||
import { createRouter } from '@tanstack/react-router'
|
import { createRouter } from '@tanstack/react-router'
|
||||||
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
|
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
|
||||||
import type { RouterContext } from './routes/__root'
|
import type { RouterContext } from './routes/__root'
|
||||||
import { routeTree } from './routeTree.gen'
|
import { routeTree } from './routeTree.gen'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建路由实例工厂函数
|
||||||
|
*
|
||||||
|
* 每次调用创建新的 QueryClient 和 Router 实例。
|
||||||
|
* 这对于 SSR 很重要,避免请求之间共享状态。
|
||||||
|
*
|
||||||
|
* @returns 配置好的 TanStack Router 实例
|
||||||
|
*/
|
||||||
export const getRouter = () => {
|
export const getRouter = () => {
|
||||||
|
// 创建 TanStack Query 客户端
|
||||||
const queryClient = new QueryClient()
|
const queryClient = new QueryClient()
|
||||||
|
|
||||||
|
// 创建路由实例
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
routeTree,
|
routeTree,
|
||||||
context: {
|
context: { queryClient } satisfies RouterContext,
|
||||||
queryClient,
|
// 启用滚动位置恢复
|
||||||
} satisfies RouterContext,
|
|
||||||
|
|
||||||
scrollRestoration: true,
|
scrollRestoration: true,
|
||||||
|
// 预加载数据立即过期,确保总是获取最新数据
|
||||||
defaultPreloadStaleTime: 0,
|
defaultPreloadStaleTime: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
setupRouterSsrQueryIntegration({
|
// 设置 SSR 查询集成
|
||||||
router,
|
// 将路由器的预取与 TanStack Query 的缓存连接起来
|
||||||
queryClient,
|
setupRouterSsrQueryIntegration({ router, queryClient })
|
||||||
})
|
|
||||||
|
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* 根布局组件
|
||||||
|
*
|
||||||
|
* 定义应用的 HTML 结构、全局样式和错误处理。
|
||||||
|
*
|
||||||
|
* 特性:
|
||||||
|
* - OpenBridge 设计系统集成 (CSS 变量主题)
|
||||||
|
* - TanStack Router 上下文配置
|
||||||
|
* - 全局错误边界和 404 处理
|
||||||
|
*/
|
||||||
import '@oicl/openbridge-webcomponents/src/palettes/variables.css'
|
import '@oicl/openbridge-webcomponents/src/palettes/variables.css'
|
||||||
import type { QueryClient } from '@tanstack/react-query'
|
import type { QueryClient } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
@@ -10,36 +20,44 @@ import { ErrorComponent } from '@/components/Error'
|
|||||||
import { NotFoundComponent } from '@/components/NotFount'
|
import { NotFoundComponent } from '@/components/NotFount'
|
||||||
import appCss from '@/styles.css?url'
|
import appCss from '@/styles.css?url'
|
||||||
|
|
||||||
|
/** 路由上下文类型 - 包含 TanStack Query 客户端 */
|
||||||
export interface RouterContext {
|
export interface RouterContext {
|
||||||
queryClient: QueryClient
|
queryClient: QueryClient
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根路由定义
|
||||||
|
*
|
||||||
|
* 配置:
|
||||||
|
* - head: 页面 meta 标签和样式表
|
||||||
|
* - shellComponent: HTML 文档结构
|
||||||
|
* - errorComponent: 错误边界回退
|
||||||
|
* - notFoundComponent: 404 页面
|
||||||
|
*/
|
||||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [
|
meta: [
|
||||||
{
|
{ charSet: 'utf-8' },
|
||||||
charSet: 'utf-8',
|
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||||
},
|
{ title: 'Token Usage Viewer' },
|
||||||
{
|
|
||||||
name: 'viewport',
|
|
||||||
content: 'width=device-width, initial-scale=1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Token Usage Viewer',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
links: [
|
|
||||||
{
|
|
||||||
rel: 'stylesheet',
|
|
||||||
href: appCss,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
|
links: [{ rel: 'stylesheet', href: appCss }],
|
||||||
}),
|
}),
|
||||||
shellComponent: RootDocument,
|
shellComponent: RootDocument,
|
||||||
errorComponent: () => <ErrorComponent />,
|
errorComponent: () => <ErrorComponent />,
|
||||||
notFoundComponent: () => <NotFoundComponent />,
|
notFoundComponent: () => <NotFoundComponent />,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML 文档根结构
|
||||||
|
*
|
||||||
|
* 设置:
|
||||||
|
* - lang="zh-Hans": 简体中文
|
||||||
|
* - data-obc-theme="day": OpenBridge 默认日间主题
|
||||||
|
* - obc-component-size-regular: OpenBridge 标准组件尺寸
|
||||||
|
*
|
||||||
|
* @param children - 页面内容
|
||||||
|
*/
|
||||||
function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
|
function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
|
||||||
return (
|
return (
|
||||||
<html lang="zh-Hans" data-obc-theme="day">
|
<html lang="zh-Hans" data-obc-theme="day">
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Token Usage Viewer 主页面
|
* Token Usage Viewer 主页面
|
||||||
*
|
*
|
||||||
* 展示 Opus/Thinking 模型的配额使用情况仪表盘
|
* 展示 claude-opus-4-5-thinking 模型的配额使用情况仪表盘。
|
||||||
|
*
|
||||||
|
* 特性:
|
||||||
|
* - SSR 预加载数据(通过 loader)
|
||||||
|
* - 每 5 分钟自动刷新配额数据
|
||||||
|
* - Tauri 环境下自动设置窗口标题
|
||||||
*/
|
*/
|
||||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
@@ -11,8 +16,16 @@ import { useEffect } from 'react'
|
|||||||
import { TokenUsageDashboard } from '@/components/TokenUsageDashboard'
|
import { TokenUsageDashboard } from '@/components/TokenUsageDashboard'
|
||||||
import { orpc } from '@/orpc'
|
import { orpc } from '@/orpc'
|
||||||
|
|
||||||
|
/** 数据自动刷新间隔 (毫秒) - 5 分钟 */
|
||||||
|
const REFETCH_INTERVAL = 5 * 60 * 1000
|
||||||
|
|
||||||
export const Route = createFileRoute('/')({
|
export const Route = createFileRoute('/')({
|
||||||
component: Home,
|
component: Home,
|
||||||
|
/**
|
||||||
|
* 路由加载器 - SSR 数据预取
|
||||||
|
*
|
||||||
|
* 在服务端渲染时预先获取使用量数据,确保首次渲染即有内容。
|
||||||
|
*/
|
||||||
loader: async ({ context }) => {
|
loader: async ({ context }) => {
|
||||||
await context.queryClient.ensureQueryData(
|
await context.queryClient.ensureQueryData(
|
||||||
orpc.usage.getUsage.queryOptions(),
|
orpc.usage.getUsage.queryOptions(),
|
||||||
@@ -20,13 +33,19 @@ export const Route = createFileRoute('/')({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首页组件
|
||||||
|
*
|
||||||
|
* 使用 useSuspenseQuery 获取并展示配额数据,
|
||||||
|
* 确保 data 不为空(由 loader 预取保证)。
|
||||||
|
*/
|
||||||
function Home() {
|
function Home() {
|
||||||
const { data } = useSuspenseQuery({
|
const { data } = useSuspenseQuery({
|
||||||
...orpc.usage.getUsage.queryOptions(),
|
...orpc.usage.getUsage.queryOptions(),
|
||||||
refetchInterval: 300000, // 每 5 分钟自动刷新
|
refetchInterval: REFETCH_INTERVAL,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 设置 Tauri 窗口标题
|
// Tauri 环境: 设置窗口标题
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isTauri()) return
|
if (!isTauri()) return
|
||||||
getCurrentWindow().setTitle('Token Usage Viewer')
|
getCurrentWindow().setTitle('Token Usage Viewer')
|
||||||
|
|||||||
291
src/styles.css
291
src/styles.css
@@ -1 +1,292 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
* OpenBridge 航海设计系统 - 四种光照条件主题
|
||||||
|
*
|
||||||
|
* 符合 IMO 航海规范,确保在各种光照条件下的可读性:
|
||||||
|
* - Day: 日间正常光照
|
||||||
|
* - Bright: 强光/阳光直射
|
||||||
|
* - Dusk: 黄昏/低光照
|
||||||
|
* - Night: 夜间模式(保护夜视能力)
|
||||||
|
* ============================================================================ */
|
||||||
|
|
||||||
|
/* Day 主题 - 日间正常光照 */
|
||||||
|
:root[data-obc-theme='day'] {
|
||||||
|
/* 容器/背景颜色 */
|
||||||
|
--container-global-color: rgb(255, 255, 255);
|
||||||
|
--container-background-color: rgb(247, 247, 247);
|
||||||
|
--container-section-color: rgb(240, 240, 240);
|
||||||
|
--container-backdrop-color: rgb(224, 224, 224);
|
||||||
|
|
||||||
|
/* 文字/元素颜色 - 深色文字确保可读性 */
|
||||||
|
--element-active-color: rgb(61, 61, 61);
|
||||||
|
--element-neutral-color: rgb(83, 83, 83);
|
||||||
|
--element-inactive-color: rgb(112, 112, 112);
|
||||||
|
--element-disabled-color: rgb(202, 202, 202);
|
||||||
|
|
||||||
|
/* 兼容旧变量名 */
|
||||||
|
--on-container-color: rgb(61, 61, 61);
|
||||||
|
--on-container-low-color: rgb(112, 112, 112);
|
||||||
|
|
||||||
|
/* 分割线/边框颜色 */
|
||||||
|
--border-outline-color: rgb(221, 221, 221);
|
||||||
|
--border-divider-color: rgb(221, 221, 221);
|
||||||
|
--border-silhouette-color: rgb(247, 247, 247);
|
||||||
|
--divider-color: rgb(221, 221, 221);
|
||||||
|
|
||||||
|
/* 交互状态 */
|
||||||
|
--element-focused-color: rgba(0, 0, 0, 0.08);
|
||||||
|
--element-hover-color: rgba(0, 0, 0, 0.12);
|
||||||
|
|
||||||
|
/* 告警颜色 */
|
||||||
|
--alert-alarm-color: rgb(227, 0, 25);
|
||||||
|
--alert-warning-color: rgb(254, 148, 19);
|
||||||
|
--alert-caution-color: rgb(255, 219, 55);
|
||||||
|
--alert-running-color: rgb(0, 131, 0);
|
||||||
|
|
||||||
|
/* 按钮/强调色 */
|
||||||
|
--primary-color: rgb(51, 84, 131);
|
||||||
|
--border-focus-color: rgb(51, 84, 131);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bright 主题 - 强光/阳光直射(高对比度) */
|
||||||
|
:root[data-obc-theme='bright'] {
|
||||||
|
/* 容器/背景颜色 - 纯白最大亮度 */
|
||||||
|
--container-global-color: rgb(255, 255, 255);
|
||||||
|
--container-background-color: rgb(255, 255, 255);
|
||||||
|
--container-section-color: rgb(255, 255, 255);
|
||||||
|
--container-backdrop-color: rgb(255, 255, 255);
|
||||||
|
|
||||||
|
/* 文字/元素颜色 - 最深黑色确保阳光下可读 */
|
||||||
|
--element-active-color: rgb(26, 26, 26);
|
||||||
|
--element-neutral-color: rgb(61, 61, 61);
|
||||||
|
--element-inactive-color: rgb(83, 83, 83);
|
||||||
|
--element-disabled-color: rgb(202, 202, 202);
|
||||||
|
|
||||||
|
/* 兼容旧变量名 */
|
||||||
|
--on-container-color: rgb(26, 26, 26);
|
||||||
|
--on-container-low-color: rgb(83, 83, 83);
|
||||||
|
|
||||||
|
/* 分割线/边框颜色 - 加粗边框增强对比 */
|
||||||
|
--border-outline-color: rgb(142, 142, 142);
|
||||||
|
--border-divider-color: rgb(142, 142, 142);
|
||||||
|
--border-silhouette-color: rgb(255, 255, 255);
|
||||||
|
--divider-color: rgb(142, 142, 142);
|
||||||
|
|
||||||
|
/* 交互状态 */
|
||||||
|
--element-focused-color: rgba(0, 0, 0, 0.12);
|
||||||
|
--element-hover-color: rgba(0, 0, 0, 0.18);
|
||||||
|
|
||||||
|
/* 告警颜色 - 饱和度更高 */
|
||||||
|
--alert-alarm-color: rgb(235, 0, 20);
|
||||||
|
--alert-warning-color: rgb(254, 148, 19);
|
||||||
|
--alert-caution-color: rgb(228, 186, 2);
|
||||||
|
--alert-running-color: rgb(6, 131, 6);
|
||||||
|
|
||||||
|
/* 按钮/强调色 - 更深的蓝色 */
|
||||||
|
--primary-color: rgb(34, 60, 97);
|
||||||
|
--border-focus-color: rgb(34, 60, 97);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dusk 主题 - 黄昏/低光照 */
|
||||||
|
:root[data-obc-theme='dusk'] {
|
||||||
|
/* 容器/背景颜色 - 深灰色减少眩光 */
|
||||||
|
--container-global-color: rgb(36, 36, 36);
|
||||||
|
--container-background-color: rgb(26, 26, 26);
|
||||||
|
--container-section-color: rgb(18, 18, 18);
|
||||||
|
--container-backdrop-color: rgb(3, 3, 3);
|
||||||
|
|
||||||
|
/* 文字/元素颜色 - 浅灰色文字 */
|
||||||
|
--element-active-color: rgb(198, 198, 198);
|
||||||
|
--element-neutral-color: rgb(165, 165, 165);
|
||||||
|
--element-inactive-color: rgb(130, 130, 130);
|
||||||
|
--element-disabled-color: rgb(58, 58, 58);
|
||||||
|
|
||||||
|
/* 兼容旧变量名 */
|
||||||
|
--on-container-color: rgb(198, 198, 198);
|
||||||
|
--on-container-low-color: rgb(130, 130, 130);
|
||||||
|
|
||||||
|
/* 分割线/边框颜色 */
|
||||||
|
--border-outline-color: rgb(0, 0, 0);
|
||||||
|
--border-divider-color: rgba(255, 255, 255, 0.09);
|
||||||
|
--border-silhouette-color: rgb(0, 0, 0);
|
||||||
|
--divider-color: rgba(255, 255, 255, 0.09);
|
||||||
|
|
||||||
|
/* 交互状态 */
|
||||||
|
--element-focused-color: rgba(255, 255, 255, 0.08);
|
||||||
|
--element-hover-color: rgba(255, 255, 255, 0.12);
|
||||||
|
|
||||||
|
/* 告警颜色 */
|
||||||
|
--alert-alarm-color: rgb(203, 19, 29);
|
||||||
|
--alert-warning-color: rgb(254, 155, 41);
|
||||||
|
--alert-caution-color: rgb(233, 195, 0);
|
||||||
|
--alert-running-color: rgb(18, 119, 15);
|
||||||
|
|
||||||
|
/* 按钮/强调色 - 亮蓝色 */
|
||||||
|
--primary-color: rgb(133, 167, 216);
|
||||||
|
--border-focus-color: rgb(133, 167, 216);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Night 主题 - 夜间模式(保护夜视能力)
|
||||||
|
* 使用红色/黄色光谱,避免蓝光影响夜视适应 */
|
||||||
|
:root[data-obc-theme='night'] {
|
||||||
|
/* 容器/背景颜色 - 纯黑背景 */
|
||||||
|
--container-global-color: rgb(10, 10, 0);
|
||||||
|
--container-background-color: rgb(0, 0, 0);
|
||||||
|
--container-section-color: rgb(0, 0, 0);
|
||||||
|
--container-backdrop-color: rgb(5, 5, 0);
|
||||||
|
|
||||||
|
/* 文字/元素颜色 - 暗黄色文字(保护夜视) */
|
||||||
|
--element-active-color: rgb(71, 71, 0);
|
||||||
|
--element-neutral-color: rgba(71, 71, 0, 0.65);
|
||||||
|
--element-inactive-color: rgba(71, 71, 0, 0.45);
|
||||||
|
--element-disabled-color: rgba(71, 71, 0, 0.25);
|
||||||
|
|
||||||
|
/* 兼容旧变量名 */
|
||||||
|
--on-container-color: rgb(71, 71, 0);
|
||||||
|
--on-container-low-color: rgba(71, 71, 0, 0.65);
|
||||||
|
|
||||||
|
/* 分割线/边框颜色 */
|
||||||
|
--border-outline-color: rgba(71, 71, 0, 0.2);
|
||||||
|
--border-divider-color: rgba(71, 71, 0, 0.2);
|
||||||
|
--border-silhouette-color: rgb(0, 0, 0);
|
||||||
|
--divider-color: rgba(71, 71, 0, 0.2);
|
||||||
|
|
||||||
|
/* 交互状态 */
|
||||||
|
--element-focused-color: rgba(71, 71, 0, 0.15);
|
||||||
|
--element-hover-color: rgba(71, 71, 0, 0.25);
|
||||||
|
|
||||||
|
/* 告警颜色 - 低亮度红/黄/绿 */
|
||||||
|
--alert-alarm-color: rgb(77, 0, 5);
|
||||||
|
--alert-warning-color: rgb(92, 44, 0);
|
||||||
|
--alert-caution-color: rgb(92, 69, 0);
|
||||||
|
--alert-running-color: rgb(23, 56, 0);
|
||||||
|
|
||||||
|
/* 按钮/强调色 - 暗绿色 */
|
||||||
|
--primary-color: rgb(0, 51, 21);
|
||||||
|
--border-focus-color: rgb(71, 71, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================ */
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--radius-2xl: calc(var(--radius) + 8px);
|
||||||
|
--radius-3xl: calc(var(--radius) + 12px);
|
||||||
|
--radius-4xl: calc(var(--radius) + 16px);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,35 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Vite 构建配置
|
||||||
|
*
|
||||||
|
* 集成 TanStack Start (SSR) + Nitro (服务端) + Tailwind CSS + React Compiler。
|
||||||
|
*
|
||||||
|
* 特性:
|
||||||
|
* - Bun 运行时优化 (nitro preset: 'bun')
|
||||||
|
* - 静态资源内联 (serveStatic: 'inline')
|
||||||
|
* - React Compiler 自动优化 (无需手动 memo)
|
||||||
|
* - 端口默认 13098
|
||||||
|
*/
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import { devtools as tanstackDevtools } from '@tanstack/devtools-vite'
|
import { devtools as tanstackDevtools } from '@tanstack/devtools-vite'
|
||||||
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
|
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import { nitro } from 'nitro/vite'
|
import { nitro } from 'nitro/vite'
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
import tsconfigPaths from 'vite-tsconfig-paths'
|
import tsconfigPaths from 'vite-tsconfig-paths'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 项目配置 (集中管理)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** 加载 .env 文件中的环境变量 */
|
||||||
|
const envVars = loadEnv('development', process.cwd(), '')
|
||||||
|
|
||||||
|
/** 开发服务器端口 (从环境变量读取,默认 13098) */
|
||||||
|
const DEV_PORT = 13098
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
// 禁止清屏,方便与 Tauri 开发工具共用终端
|
||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
|
|
||||||
build: {
|
build: {
|
||||||
cssMinify: 'esbuild', // 使用 esbuild 替代 lightningcss 避免第三方 CSS 兼容性问题
|
// 使用 esbuild 进行 CSS 压缩
|
||||||
|
// 避免 lightningcss 处理第三方 CSS 时的兼容性问题
|
||||||
|
cssMinify: 'esbuild',
|
||||||
},
|
},
|
||||||
|
|
||||||
plugins: [
|
plugins: [
|
||||||
|
// TanStack 开发工具
|
||||||
tanstackDevtools(),
|
tanstackDevtools(),
|
||||||
|
|
||||||
|
// Nitro 服务端框架 (Bun 运行时)
|
||||||
nitro({
|
nitro({
|
||||||
preset: 'bun',
|
preset: 'bun',
|
||||||
serveStatic: 'inline',
|
serveStatic: 'inline',
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// TypeScript 路径别名 (@/* -> src/*)
|
||||||
tsconfigPaths(),
|
tsconfigPaths(),
|
||||||
|
|
||||||
|
// Tailwind CSS v4
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
|
|
||||||
|
// TanStack Start SSR 框架
|
||||||
tanstackStart(),
|
tanstackStart(),
|
||||||
|
|
||||||
|
// React + Babel (启用 React Compiler)
|
||||||
react({
|
react({
|
||||||
babel: {
|
babel: {
|
||||||
plugins: ['babel-plugin-react-compiler'],
|
plugins: ['babel-plugin-react-compiler'],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: DEV_PORT,
|
||||||
strictPort: true,
|
// 如果端口被占用则报错,而不是自动切换端口
|
||||||
|
strictPort: false,
|
||||||
watch: {
|
watch: {
|
||||||
|
// 忽略 Tauri 源码目录,避免不必要的重编译
|
||||||
ignored: ['**/src-tauri/**'],
|
ignored: ['**/src-tauri/**'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user