diff --git a/.gitignore b/.gitignore
index c8335ec..75ebf18 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,3 +32,4 @@ lerna-debug.log*
# misc
.DS_Store
wrangler.toml
+public*/
diff --git a/frontend/.env.development b/frontend/.env.development
new file mode 100644
index 0000000..0c18217
--- /dev/null
+++ b/frontend/.env.development
@@ -0,0 +1 @@
+VITE_API_BASE_URL=http://localhost:8787
diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 0000000..a547bf3
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/frontend/README.md b/frontend/README.md
new file mode 100644
index 0000000..c8a708c
--- /dev/null
+++ b/frontend/README.md
@@ -0,0 +1,69 @@
+# AWSL One API Frontend
+
+Modern React + Vite + TypeScript frontend for AWSL One API management platform.
+
+## Features
+
+- Modern React 19 with TypeScript
+- Vite for fast development and building
+- Tailwind CSS for styling
+- shadcn/ui components
+- TanStack Query for data fetching
+- Zustand for state management
+- React Router for routing
+- Full CRUD operations for channels, tokens, and pricing
+- Dark mode support
+
+## Development
+
+```bash
+# Install dependencies
+pnpm install
+
+# Start development server
+pnpm dev
+
+# Build for production
+pnpm build
+
+# Preview production build
+pnpm preview
+```
+
+## Project Structure
+
+```
+src/
+├── api/ # API client
+├── components/ # React components
+│ ├── layout/ # Layout components
+│ └── ui/ # UI components (shadcn/ui)
+├── lib/ # Utility functions
+├── pages/ # Page components
+├── store/ # Zustand stores
+├── types/ # TypeScript types
+├── App.tsx # Main app component
+├── main.tsx # Entry point
+└── index.css # Global styles
+```
+
+## Environment Variables
+
+No environment variables required. The frontend communicates with the backend API at the same domain.
+
+## Features
+
+- **Dashboard**: Overview and quick start guide
+- **API Test**: Test API endpoints with custom requests
+- **Channels**: Manage OpenAI, Azure OpenAI, and Claude channels
+- **Tokens**: Create and manage API tokens with quota limits
+- **Pricing**: Configure model pricing multipliers
+- **Database**: Initialize database tables
+
+## Building
+
+```bash
+pnpm build
+```
+
+The built files will be in the `dist` directory.
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
new file mode 100644
index 0000000..d1560e3
--- /dev/null
+++ b/frontend/eslint.config.js
@@ -0,0 +1,35 @@
+import js from '@eslint/js'
+import globals from 'globals'
+import reactHooks from 'eslint-plugin-react-hooks'
+import reactRefresh from 'eslint-plugin-react-refresh'
+import tseslint from 'typescript-eslint'
+
+export default tseslint.config(
+ { ignores: ['dist'] },
+ {
+ extends: [js.configs.recommended, ...tseslint.configs.recommended],
+ files: ['**/*.{ts,tsx}'],
+ languageOptions: {
+ ecmaVersion: 2020,
+ globals: globals.browser,
+ },
+ plugins: {
+ 'react-hooks': reactHooks,
+ 'react-refresh': reactRefresh,
+ },
+ rules: {
+ ...reactHooks.configs.recommended.rules,
+ 'react-hooks/set-state-in-effect': 'off', // Allow setState in useEffect for React Query data syncing
+ 'react-hooks/refs': 'off', // Allow ref access patterns
+ 'react-refresh/only-export-components': [
+ 'warn',
+ { allowConstantExport: true },
+ ],
+ '@typescript-eslint/no-explicit-any': 'warn',
+ '@typescript-eslint/no-unused-vars': ['warn', {
+ argsIgnorePattern: '^_',
+ caughtErrors: 'none' // 忽略 catch 中未使用的 error 变量
+ }],
+ },
+ },
+)
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..72cba87
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Awsl One API - 管理控制台
+
+
+
+
+
+
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..505e702
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "frontend",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "lint": "eslint .",
+ "preview": "vite preview"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.39.2",
+ "@types/node": "^25.0.3",
+ "@types/react": "^19.2.7",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^5.1.2",
+ "autoprefixer": "^10.4.23",
+ "eslint": "^9.39.2",
+ "eslint-plugin-react-hooks": "^7.0.1",
+ "eslint-plugin-react-refresh": "^0.4.26",
+ "globals": "^16.5.0",
+ "postcss": "^8.5.6",
+ "tailwindcss": "^4.1.18",
+ "typescript": "~5.9.3",
+ "typescript-eslint": "^8.50.0",
+ "vite": "^7.2.4"
+ },
+ "dependencies": {
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-slot": "^1.2.4",
+ "@tailwindcss/postcss": "^4.1.18",
+ "@tanstack/react-query": "^5.90.12",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "lucide-react": "^0.562.0",
+ "react": "^19.2.3",
+ "react-dom": "^19.2.3",
+ "react-router-dom": "^7.11.0",
+ "tailwind-merge": "^3.4.0",
+ "zustand": "^5.0.9"
+ }
+}
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
new file mode 100644
index 0000000..9f93b99
--- /dev/null
+++ b/frontend/pnpm-lock.yaml
@@ -0,0 +1,2970 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ '@radix-ui/react-dialog':
+ specifier: ^1.1.15
+ version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-slot':
+ specifier: ^1.2.4
+ version: 1.2.4(@types/react@19.2.7)(react@19.2.3)
+ '@tailwindcss/postcss':
+ specifier: ^4.1.18
+ version: 4.1.18
+ '@tanstack/react-query':
+ specifier: ^5.90.12
+ version: 5.90.12(react@19.2.3)
+ class-variance-authority:
+ specifier: ^0.7.1
+ version: 0.7.1
+ clsx:
+ specifier: ^2.1.1
+ version: 2.1.1
+ lucide-react:
+ specifier: ^0.562.0
+ version: 0.562.0(react@19.2.3)
+ react:
+ specifier: ^19.2.3
+ version: 19.2.3
+ react-dom:
+ specifier: ^19.2.3
+ version: 19.2.3(react@19.2.3)
+ react-router-dom:
+ specifier: ^7.11.0
+ version: 7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ tailwind-merge:
+ specifier: ^3.4.0
+ version: 3.4.0
+ zustand:
+ specifier: ^5.0.9
+ version: 5.0.9(@types/react@19.2.7)(react@19.2.3)
+ devDependencies:
+ '@eslint/js':
+ specifier: ^9.39.2
+ version: 9.39.2
+ '@types/node':
+ specifier: ^25.0.3
+ version: 25.0.3
+ '@types/react':
+ specifier: ^19.2.7
+ version: 19.2.7
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.7)
+ '@vitejs/plugin-react':
+ specifier: ^5.1.2
+ version: 5.1.2(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2))
+ autoprefixer:
+ specifier: ^10.4.23
+ version: 10.4.23(postcss@8.5.6)
+ eslint:
+ specifier: ^9.39.2
+ version: 9.39.2(jiti@2.6.1)
+ eslint-plugin-react-hooks:
+ specifier: ^7.0.1
+ version: 7.0.1(eslint@9.39.2(jiti@2.6.1))
+ eslint-plugin-react-refresh:
+ specifier: ^0.4.26
+ version: 0.4.26(eslint@9.39.2(jiti@2.6.1))
+ globals:
+ specifier: ^16.5.0
+ version: 16.5.0
+ postcss:
+ specifier: ^8.5.6
+ version: 8.5.6
+ tailwindcss:
+ specifier: ^4.1.18
+ version: 4.1.18
+ typescript:
+ specifier: ~5.9.3
+ version: 5.9.3
+ typescript-eslint:
+ specifier: ^8.50.0
+ version: 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
+ vite:
+ specifier: ^7.2.4
+ version: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)
+
+packages:
+
+ '@alloc/quick-lru@5.2.0':
+ resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
+ engines: {node: '>=10'}
+
+ '@babel/code-frame@7.27.1':
+ resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/compat-data@7.28.5':
+ resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/core@7.28.5':
+ resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/generator@7.28.5':
+ resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-compilation-targets@7.27.2':
+ resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-globals@7.28.0':
+ resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-imports@7.27.1':
+ resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-transforms@7.28.3':
+ resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+
+ '@babel/helper-plugin-utils@7.27.1':
+ resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-string-parser@7.27.1':
+ resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-identifier@7.28.5':
+ resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-option@7.27.1':
+ resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helpers@7.28.4':
+ resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/parser@7.28.5':
+ resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ '@babel/plugin-transform-react-jsx-self@7.27.1':
+ resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/plugin-transform-react-jsx-source@7.27.1':
+ resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/template@7.27.2':
+ resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/traverse@7.28.5':
+ resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/types@7.28.5':
+ resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
+ engines: {node: '>=6.9.0'}
+
+ '@esbuild/aix-ppc64@0.27.2':
+ resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.27.2':
+ resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.27.2':
+ resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.27.2':
+ resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.27.2':
+ resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.27.2':
+ resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.27.2':
+ resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.27.2':
+ resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.27.2':
+ resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.27.2':
+ resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.27.2':
+ resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.27.2':
+ resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.27.2':
+ resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.27.2':
+ resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.27.2':
+ resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.27.2':
+ resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.27.2':
+ resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-arm64@0.27.2':
+ resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.27.2':
+ resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-arm64@0.27.2':
+ resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.27.2':
+ resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openharmony-arm64@0.27.2':
+ resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@esbuild/sunos-x64@0.27.2':
+ resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.27.2':
+ resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.27.2':
+ resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.27.2':
+ resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
+ '@eslint-community/eslint-utils@4.9.0':
+ resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ peerDependencies:
+ eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
+
+ '@eslint-community/regexpp@4.12.2':
+ resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
+ engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
+
+ '@eslint/config-array@0.21.1':
+ resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/config-helpers@0.4.2':
+ resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/core@0.17.0':
+ resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/eslintrc@3.3.3':
+ resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/js@9.39.2':
+ resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/object-schema@2.1.7':
+ resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/plugin-kit@0.4.1':
+ resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@humanfs/core@0.19.1':
+ resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
+ engines: {node: '>=18.18.0'}
+
+ '@humanfs/node@0.16.7':
+ resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==}
+ engines: {node: '>=18.18.0'}
+
+ '@humanwhocodes/module-importer@1.0.1':
+ resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
+ engines: {node: '>=12.22'}
+
+ '@humanwhocodes/retry@0.4.3':
+ resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
+ engines: {node: '>=18.18'}
+
+ '@jridgewell/gen-mapping@0.3.13':
+ resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
+
+ '@jridgewell/remapping@2.3.5':
+ resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
+
+ '@jridgewell/resolve-uri@3.1.2':
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+ engines: {node: '>=6.0.0'}
+
+ '@jridgewell/sourcemap-codec@1.5.5':
+ resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+
+ '@radix-ui/primitive@1.1.3':
+ resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
+
+ '@radix-ui/react-compose-refs@1.1.2':
+ resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-context@1.1.2':
+ resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-dialog@1.1.15':
+ resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==}
+ 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
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-dismissable-layer@1.1.11':
+ resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==}
+ 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
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-focus-guards@1.1.3':
+ resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-focus-scope@1.1.7':
+ resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==}
+ 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
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-id@1.1.1':
+ resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-portal@1.1.9':
+ resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
+ 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
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-presence@1.1.5':
+ resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==}
+ 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
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-primitive@2.1.3':
+ resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
+ 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
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-slot@1.2.3':
+ resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-slot@1.2.4':
+ resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-callback-ref@1.1.1':
+ resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-controllable-state@1.2.2':
+ resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-effect-event@0.0.2':
+ resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-escape-keydown@1.1.1':
+ resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-layout-effect@1.1.1':
+ resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@rolldown/pluginutils@1.0.0-beta.53':
+ resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==}
+
+ '@rollup/rollup-android-arm-eabi@4.53.5':
+ resolution: {integrity: sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==}
+ cpu: [arm]
+ os: [android]
+
+ '@rollup/rollup-android-arm64@4.53.5':
+ resolution: {integrity: sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw==}
+ cpu: [arm64]
+ os: [android]
+
+ '@rollup/rollup-darwin-arm64@4.53.5':
+ resolution: {integrity: sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rollup/rollup-darwin-x64@4.53.5':
+ resolution: {integrity: sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rollup/rollup-freebsd-arm64@4.53.5':
+ resolution: {integrity: sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw==}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@rollup/rollup-freebsd-x64@4.53.5':
+ resolution: {integrity: sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.53.5':
+ resolution: {integrity: sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==}
+ cpu: [arm]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm-musleabihf@4.53.5':
+ resolution: {integrity: sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==}
+ cpu: [arm]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm64-gnu@4.53.5':
+ resolution: {integrity: sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm64-musl@4.53.5':
+ resolution: {integrity: sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rollup/rollup-linux-loong64-gnu@4.53.5':
+ resolution: {integrity: sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==}
+ cpu: [loong64]
+ os: [linux]
+
+ '@rollup/rollup-linux-ppc64-gnu@4.53.5':
+ resolution: {integrity: sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@rollup/rollup-linux-riscv64-gnu@4.53.5':
+ resolution: {integrity: sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@rollup/rollup-linux-riscv64-musl@4.53.5':
+ resolution: {integrity: sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@rollup/rollup-linux-s390x-gnu@4.53.5':
+ resolution: {integrity: sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==}
+ cpu: [s390x]
+ os: [linux]
+
+ '@rollup/rollup-linux-x64-gnu@4.53.5':
+ resolution: {integrity: sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==}
+ cpu: [x64]
+ os: [linux]
+
+ '@rollup/rollup-linux-x64-musl@4.53.5':
+ resolution: {integrity: sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==}
+ cpu: [x64]
+ os: [linux]
+
+ '@rollup/rollup-openharmony-arm64@4.53.5':
+ resolution: {integrity: sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rollup/rollup-win32-arm64-msvc@4.53.5':
+ resolution: {integrity: sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rollup/rollup-win32-ia32-msvc@4.53.5':
+ resolution: {integrity: sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-gnu@4.53.5':
+ resolution: {integrity: sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-msvc@4.53.5':
+ resolution: {integrity: sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==}
+ cpu: [x64]
+ os: [win32]
+
+ '@tailwindcss/node@4.1.18':
+ resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==}
+
+ '@tailwindcss/oxide-android-arm64@4.1.18':
+ resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [android]
+
+ '@tailwindcss/oxide-darwin-arm64@4.1.18':
+ resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-darwin-x64@4.1.18':
+ resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-freebsd-x64@4.1.18':
+ resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18':
+ resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==}
+ engines: {node: '>= 10'}
+ cpu: [arm]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.1.18':
+ resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.1.18':
+ resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.1.18':
+ resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-x64-musl@4.1.18':
+ resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+
+ '@tailwindcss/oxide-wasm32-wasi@4.1.18':
+ resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
+ engines: {node: '>=14.0.0'}
+ cpu: [wasm32]
+ bundledDependencies:
+ - '@napi-rs/wasm-runtime'
+ - '@emnapi/core'
+ - '@emnapi/runtime'
+ - '@tybys/wasm-util'
+ - '@emnapi/wasi-threads'
+ - tslib
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.1.18':
+ resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.1.18':
+ resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [win32]
+
+ '@tailwindcss/oxide@4.1.18':
+ resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==}
+ engines: {node: '>= 10'}
+
+ '@tailwindcss/postcss@4.1.18':
+ resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==}
+
+ '@tanstack/query-core@5.90.12':
+ resolution: {integrity: sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==}
+
+ '@tanstack/react-query@5.90.12':
+ resolution: {integrity: sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==}
+ peerDependencies:
+ react: ^18 || ^19
+
+ '@types/babel__core@7.20.5':
+ resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
+
+ '@types/babel__generator@7.27.0':
+ resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==}
+
+ '@types/babel__template@7.4.4':
+ resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==}
+
+ '@types/babel__traverse@7.28.0':
+ resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
+
+ '@types/estree@1.0.8':
+ resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+
+ '@types/json-schema@7.0.15':
+ resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
+
+ '@types/node@25.0.3':
+ resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==}
+
+ '@types/react-dom@19.2.3':
+ resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
+ peerDependencies:
+ '@types/react': ^19.2.0
+
+ '@types/react@19.2.7':
+ resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
+
+ '@typescript-eslint/eslint-plugin@8.50.0':
+ resolution: {integrity: sha512-O7QnmOXYKVtPrfYzMolrCTfkezCJS9+ljLdKW/+DCvRsc3UAz+sbH6Xcsv7p30+0OwUbeWfUDAQE0vpabZ3QLg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ '@typescript-eslint/parser': ^8.50.0
+ eslint: ^8.57.0 || ^9.0.0
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/parser@8.50.0':
+ resolution: {integrity: sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/project-service@8.50.0':
+ resolution: {integrity: sha512-Cg/nQcL1BcoTijEWyx4mkVC56r8dj44bFDvBdygifuS20f3OZCHmFbjF34DPSi07kwlFvqfv/xOLnJ5DquxSGQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/scope-manager@8.50.0':
+ resolution: {integrity: sha512-xCwfuCZjhIqy7+HKxBLrDVT5q/iq7XBVBXLn57RTIIpelLtEIZHXAF/Upa3+gaCpeV1NNS5Z9A+ID6jn50VD4A==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/tsconfig-utils@8.50.0':
+ resolution: {integrity: sha512-vxd3G/ybKTSlm31MOA96gqvrRGv9RJ7LGtZCn2Vrc5htA0zCDvcMqUkifcjrWNNKXHUU3WCkYOzzVSFBd0wa2w==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/type-utils@8.50.0':
+ resolution: {integrity: sha512-7OciHT2lKCewR0mFoBrvZJ4AXTMe/sYOe87289WAViOocEmDjjv8MvIOT2XESuKj9jp8u3SZYUSh89QA4S1kQw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/types@8.50.0':
+ resolution: {integrity: sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/typescript-estree@8.50.0':
+ resolution: {integrity: sha512-W7SVAGBR/IX7zm1t70Yujpbk+zdPq/u4soeFSknWFdXIFuWsBGBOUu/Tn/I6KHSKvSh91OiMuaSnYp3mtPt5IQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/utils@8.50.0':
+ resolution: {integrity: sha512-87KgUXET09CRjGCi2Ejxy3PULXna63/bMYv72tCAlDJC3Yqwln0HiFJ3VJMst2+mEtNtZu5oFvX4qJGjKsnAgg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/visitor-keys@8.50.0':
+ resolution: {integrity: sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@vitejs/plugin-react@5.1.2':
+ resolution: {integrity: sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ peerDependencies:
+ vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
+
+ acorn-jsx@5.3.2:
+ resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
+ peerDependencies:
+ acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
+
+ acorn@8.15.0:
+ resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
+ engines: {node: '>=0.4.0'}
+ hasBin: true
+
+ ajv@6.12.6:
+ resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
+
+ ansi-styles@4.3.0:
+ resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
+ engines: {node: '>=8'}
+
+ argparse@2.0.1:
+ resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
+
+ aria-hidden@1.2.6:
+ resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
+ engines: {node: '>=10'}
+
+ autoprefixer@10.4.23:
+ resolution: {integrity: sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==}
+ engines: {node: ^10 || ^12 || >=14}
+ hasBin: true
+ peerDependencies:
+ postcss: ^8.1.0
+
+ balanced-match@1.0.2:
+ resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+
+ baseline-browser-mapping@2.9.11:
+ resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==}
+ hasBin: true
+
+ brace-expansion@1.1.12:
+ resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
+
+ brace-expansion@2.0.2:
+ resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
+
+ browserslist@4.28.1:
+ resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
+ engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
+ hasBin: true
+
+ callsites@3.1.0:
+ resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
+ engines: {node: '>=6'}
+
+ caniuse-lite@1.0.30001761:
+ resolution: {integrity: sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==}
+
+ chalk@4.1.2:
+ resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
+ engines: {node: '>=10'}
+
+ class-variance-authority@0.7.1:
+ resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
+
+ clsx@2.1.1:
+ resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
+ engines: {node: '>=6'}
+
+ color-convert@2.0.1:
+ resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
+ engines: {node: '>=7.0.0'}
+
+ color-name@1.1.4:
+ resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+
+ concat-map@0.0.1:
+ resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
+
+ convert-source-map@2.0.0:
+ resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+
+ cookie@1.1.1:
+ resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
+ engines: {node: '>=18'}
+
+ cross-spawn@7.0.6:
+ resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
+ engines: {node: '>= 8'}
+
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ deep-is@0.1.4:
+ resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
+
+ detect-libc@2.1.2:
+ resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+ engines: {node: '>=8'}
+
+ detect-node-es@1.1.0:
+ resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
+
+ electron-to-chromium@1.5.267:
+ resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
+
+ enhanced-resolve@5.18.4:
+ resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==}
+ engines: {node: '>=10.13.0'}
+
+ esbuild@0.27.2:
+ resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ escalade@3.2.0:
+ resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
+ engines: {node: '>=6'}
+
+ escape-string-regexp@4.0.0:
+ resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
+ engines: {node: '>=10'}
+
+ eslint-plugin-react-hooks@7.0.1:
+ resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0
+
+ eslint-plugin-react-refresh@0.4.26:
+ resolution: {integrity: sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==}
+ peerDependencies:
+ eslint: '>=8.40'
+
+ eslint-scope@8.4.0:
+ resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ eslint-visitor-keys@3.4.3:
+ resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+
+ eslint-visitor-keys@4.2.1:
+ resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ eslint@9.39.2:
+ resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ hasBin: true
+ peerDependencies:
+ jiti: '*'
+ peerDependenciesMeta:
+ jiti:
+ optional: true
+
+ espree@10.4.0:
+ resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ esquery@1.6.0:
+ resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==}
+ engines: {node: '>=0.10'}
+
+ esrecurse@4.3.0:
+ resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
+ engines: {node: '>=4.0'}
+
+ estraverse@5.3.0:
+ resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
+ engines: {node: '>=4.0'}
+
+ esutils@2.0.3:
+ resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
+ engines: {node: '>=0.10.0'}
+
+ fast-deep-equal@3.1.3:
+ resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
+
+ fast-json-stable-stringify@2.1.0:
+ resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
+
+ fast-levenshtein@2.0.6:
+ resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
+
+ fdir@6.5.0:
+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+ engines: {node: '>=12.0.0'}
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
+ file-entry-cache@8.0.0:
+ resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
+ engines: {node: '>=16.0.0'}
+
+ find-up@5.0.0:
+ resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
+ engines: {node: '>=10'}
+
+ flat-cache@4.0.1:
+ resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
+ engines: {node: '>=16'}
+
+ flatted@3.3.3:
+ resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
+
+ fraction.js@5.3.4:
+ resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ gensync@1.0.0-beta.2:
+ resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
+ engines: {node: '>=6.9.0'}
+
+ get-nonce@1.0.1:
+ resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
+ engines: {node: '>=6'}
+
+ glob-parent@6.0.2:
+ resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
+ engines: {node: '>=10.13.0'}
+
+ globals@14.0.0:
+ resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
+ engines: {node: '>=18'}
+
+ globals@16.5.0:
+ resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==}
+ engines: {node: '>=18'}
+
+ graceful-fs@4.2.11:
+ resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+
+ has-flag@4.0.0:
+ resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
+ engines: {node: '>=8'}
+
+ hermes-estree@0.25.1:
+ resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
+
+ hermes-parser@0.25.1:
+ resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
+
+ ignore@5.3.2:
+ resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
+ engines: {node: '>= 4'}
+
+ ignore@7.0.5:
+ resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
+ engines: {node: '>= 4'}
+
+ import-fresh@3.3.1:
+ resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
+ engines: {node: '>=6'}
+
+ imurmurhash@0.1.4:
+ resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
+ engines: {node: '>=0.8.19'}
+
+ is-extglob@2.1.1:
+ resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
+ engines: {node: '>=0.10.0'}
+
+ is-glob@4.0.3:
+ resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
+ engines: {node: '>=0.10.0'}
+
+ isexe@2.0.0:
+ resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+
+ jiti@2.6.1:
+ resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
+ hasBin: true
+
+ js-tokens@4.0.0:
+ resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+
+ js-yaml@4.1.1:
+ resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
+ hasBin: true
+
+ jsesc@3.1.0:
+ resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ json-buffer@3.0.1:
+ resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
+
+ json-schema-traverse@0.4.1:
+ resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
+
+ json-stable-stringify-without-jsonify@1.0.1:
+ resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
+
+ json5@2.2.3:
+ resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ keyv@4.5.4:
+ resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
+
+ levn@0.4.1:
+ resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
+ engines: {node: '>= 0.8.0'}
+
+ lightningcss-android-arm64@1.30.2:
+ resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [android]
+
+ lightningcss-darwin-arm64@1.30.2:
+ resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ lightningcss-darwin-x64@1.30.2:
+ resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ lightningcss-freebsd-x64@1.30.2:
+ resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ lightningcss-linux-arm-gnueabihf@1.30.2:
+ resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm]
+ os: [linux]
+
+ lightningcss-linux-arm64-gnu@1.30.2:
+ resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ lightningcss-linux-arm64-musl@1.30.2:
+ resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ lightningcss-linux-x64-gnu@1.30.2:
+ resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+
+ lightningcss-linux-x64-musl@1.30.2:
+ resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+
+ lightningcss-win32-arm64-msvc@1.30.2:
+ resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ lightningcss-win32-x64-msvc@1.30.2:
+ resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [win32]
+
+ lightningcss@1.30.2:
+ resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==}
+ engines: {node: '>= 12.0.0'}
+
+ locate-path@6.0.0:
+ resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
+ engines: {node: '>=10'}
+
+ lodash.merge@4.6.2:
+ resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
+
+ lru-cache@5.1.1:
+ resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+
+ lucide-react@0.562.0:
+ resolution: {integrity: sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==}
+ peerDependencies:
+ react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ magic-string@0.30.21:
+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+
+ minimatch@3.1.2:
+ resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
+
+ minimatch@9.0.5:
+ resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
+ engines: {node: '>=16 || 14 >=14.17'}
+
+ ms@2.1.3:
+ resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+ nanoid@3.3.11:
+ resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ natural-compare@1.4.0:
+ resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
+
+ node-releases@2.0.27:
+ resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
+
+ optionator@0.9.4:
+ resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
+ engines: {node: '>= 0.8.0'}
+
+ p-limit@3.1.0:
+ resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
+ engines: {node: '>=10'}
+
+ p-locate@5.0.0:
+ resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
+ engines: {node: '>=10'}
+
+ parent-module@1.0.1:
+ resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
+ engines: {node: '>=6'}
+
+ path-exists@4.0.0:
+ resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
+ engines: {node: '>=8'}
+
+ path-key@3.1.1:
+ resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
+ engines: {node: '>=8'}
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ picomatch@4.0.3:
+ resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
+ engines: {node: '>=12'}
+
+ postcss-value-parser@4.2.0:
+ resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
+
+ postcss@8.5.6:
+ resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ prelude-ls@1.2.1:
+ resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
+ engines: {node: '>= 0.8.0'}
+
+ punycode@2.3.1:
+ resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
+ engines: {node: '>=6'}
+
+ react-dom@19.2.3:
+ resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==}
+ peerDependencies:
+ react: ^19.2.3
+
+ react-refresh@0.18.0:
+ resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
+ engines: {node: '>=0.10.0'}
+
+ react-remove-scroll-bar@2.3.8:
+ resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ react-remove-scroll@2.7.2:
+ resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ react-router-dom@7.11.0:
+ resolution: {integrity: sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: '>=18'
+ react-dom: '>=18'
+
+ react-router@7.11.0:
+ resolution: {integrity: sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: '>=18'
+ react-dom: '>=18'
+ peerDependenciesMeta:
+ react-dom:
+ optional: true
+
+ react-style-singleton@2.2.3:
+ resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ react@19.2.3:
+ resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
+ engines: {node: '>=0.10.0'}
+
+ resolve-from@4.0.0:
+ resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
+ engines: {node: '>=4'}
+
+ rollup@4.53.5:
+ resolution: {integrity: sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==}
+ engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+ hasBin: true
+
+ scheduler@0.27.0:
+ resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
+
+ semver@6.3.1:
+ resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
+ hasBin: true
+
+ semver@7.7.3:
+ resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ set-cookie-parser@2.7.2:
+ resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
+
+ shebang-command@2.0.0:
+ resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
+ engines: {node: '>=8'}
+
+ shebang-regex@3.0.0:
+ resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
+ engines: {node: '>=8'}
+
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+
+ strip-json-comments@3.1.1:
+ resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
+ engines: {node: '>=8'}
+
+ supports-color@7.2.0:
+ resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
+ engines: {node: '>=8'}
+
+ tailwind-merge@3.4.0:
+ resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==}
+
+ tailwindcss@4.1.18:
+ resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==}
+
+ tapable@2.3.0:
+ resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
+ engines: {node: '>=6'}
+
+ tinyglobby@0.2.15:
+ resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
+ engines: {node: '>=12.0.0'}
+
+ ts-api-utils@2.1.0:
+ resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==}
+ engines: {node: '>=18.12'}
+ peerDependencies:
+ typescript: '>=4.8.4'
+
+ tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
+ type-check@0.4.0:
+ resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
+ engines: {node: '>= 0.8.0'}
+
+ typescript-eslint@8.50.0:
+ resolution: {integrity: sha512-Q1/6yNUmCpH94fbgMUMg2/BSAr/6U7GBk61kZTv1/asghQOWOjTlp9K8mixS5NcJmm2creY+UFfGeW/+OcA64A==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0
+ typescript: '>=4.8.4 <6.0.0'
+
+ typescript@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ undici-types@7.16.0:
+ resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
+
+ update-browserslist-db@1.2.3:
+ resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
+ hasBin: true
+ peerDependencies:
+ browserslist: '>= 4.21.0'
+
+ uri-js@4.4.1:
+ resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
+
+ use-callback-ref@1.3.3:
+ resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ use-sidecar@1.1.3:
+ resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ vite@7.3.0:
+ resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^20.19.0 || >=22.12.0
+ jiti: '>=1.21.0'
+ less: ^4.0.0
+ lightningcss: ^1.21.0
+ sass: ^1.70.0
+ sass-embedded: ^1.70.0
+ stylus: '>=0.54.8'
+ sugarss: ^5.0.0
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ which@2.0.2:
+ resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
+ engines: {node: '>= 8'}
+ hasBin: true
+
+ word-wrap@1.2.5:
+ resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
+ engines: {node: '>=0.10.0'}
+
+ yallist@3.1.1:
+ resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+
+ yocto-queue@0.1.0:
+ resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
+ engines: {node: '>=10'}
+
+ zod-validation-error@4.0.2:
+ resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ zod: ^3.25.0 || ^4.0.0
+
+ zod@4.2.1:
+ resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==}
+
+ zustand@5.0.9:
+ resolution: {integrity: sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==}
+ engines: {node: '>=12.20.0'}
+ peerDependencies:
+ '@types/react': '>=18.0.0'
+ immer: '>=9.0.6'
+ react: '>=18.0.0'
+ use-sync-external-store: '>=1.2.0'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ immer:
+ optional: true
+ react:
+ optional: true
+ use-sync-external-store:
+ optional: true
+
+snapshots:
+
+ '@alloc/quick-lru@5.2.0': {}
+
+ '@babel/code-frame@7.27.1':
+ dependencies:
+ '@babel/helper-validator-identifier': 7.28.5
+ js-tokens: 4.0.0
+ picocolors: 1.1.1
+
+ '@babel/compat-data@7.28.5': {}
+
+ '@babel/core@7.28.5':
+ dependencies:
+ '@babel/code-frame': 7.27.1
+ '@babel/generator': 7.28.5
+ '@babel/helper-compilation-targets': 7.27.2
+ '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5)
+ '@babel/helpers': 7.28.4
+ '@babel/parser': 7.28.5
+ '@babel/template': 7.27.2
+ '@babel/traverse': 7.28.5
+ '@babel/types': 7.28.5
+ '@jridgewell/remapping': 2.3.5
+ convert-source-map: 2.0.0
+ debug: 4.4.3
+ gensync: 1.0.0-beta.2
+ json5: 2.2.3
+ semver: 6.3.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/generator@7.28.5':
+ dependencies:
+ '@babel/parser': 7.28.5
+ '@babel/types': 7.28.5
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+ jsesc: 3.1.0
+
+ '@babel/helper-compilation-targets@7.27.2':
+ dependencies:
+ '@babel/compat-data': 7.28.5
+ '@babel/helper-validator-option': 7.27.1
+ browserslist: 4.28.1
+ lru-cache: 5.1.1
+ semver: 6.3.1
+
+ '@babel/helper-globals@7.28.0': {}
+
+ '@babel/helper-module-imports@7.27.1':
+ dependencies:
+ '@babel/traverse': 7.28.5
+ '@babel/types': 7.28.5
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)':
+ dependencies:
+ '@babel/core': 7.28.5
+ '@babel/helper-module-imports': 7.27.1
+ '@babel/helper-validator-identifier': 7.28.5
+ '@babel/traverse': 7.28.5
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-plugin-utils@7.27.1': {}
+
+ '@babel/helper-string-parser@7.27.1': {}
+
+ '@babel/helper-validator-identifier@7.28.5': {}
+
+ '@babel/helper-validator-option@7.27.1': {}
+
+ '@babel/helpers@7.28.4':
+ dependencies:
+ '@babel/template': 7.27.2
+ '@babel/types': 7.28.5
+
+ '@babel/parser@7.28.5':
+ dependencies:
+ '@babel/types': 7.28.5
+
+ '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5)':
+ dependencies:
+ '@babel/core': 7.28.5
+ '@babel/helper-plugin-utils': 7.27.1
+
+ '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.5)':
+ dependencies:
+ '@babel/core': 7.28.5
+ '@babel/helper-plugin-utils': 7.27.1
+
+ '@babel/template@7.27.2':
+ dependencies:
+ '@babel/code-frame': 7.27.1
+ '@babel/parser': 7.28.5
+ '@babel/types': 7.28.5
+
+ '@babel/traverse@7.28.5':
+ dependencies:
+ '@babel/code-frame': 7.27.1
+ '@babel/generator': 7.28.5
+ '@babel/helper-globals': 7.28.0
+ '@babel/parser': 7.28.5
+ '@babel/template': 7.27.2
+ '@babel/types': 7.28.5
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/types@7.28.5':
+ dependencies:
+ '@babel/helper-string-parser': 7.27.1
+ '@babel/helper-validator-identifier': 7.28.5
+
+ '@esbuild/aix-ppc64@0.27.2':
+ optional: true
+
+ '@esbuild/android-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/android-arm@0.27.2':
+ optional: true
+
+ '@esbuild/android-x64@0.27.2':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/darwin-x64@0.27.2':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.27.2':
+ optional: true
+
+ '@esbuild/linux-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/linux-arm@0.27.2':
+ optional: true
+
+ '@esbuild/linux-ia32@0.27.2':
+ optional: true
+
+ '@esbuild/linux-loong64@0.27.2':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.27.2':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.27.2':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.27.2':
+ optional: true
+
+ '@esbuild/linux-s390x@0.27.2':
+ optional: true
+
+ '@esbuild/linux-x64@0.27.2':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.27.2':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.27.2':
+ optional: true
+
+ '@esbuild/openharmony-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/sunos-x64@0.27.2':
+ optional: true
+
+ '@esbuild/win32-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/win32-ia32@0.27.2':
+ optional: true
+
+ '@esbuild/win32-x64@0.27.2':
+ optional: true
+
+ '@eslint-community/eslint-utils@4.9.0(eslint@9.39.2(jiti@2.6.1))':
+ dependencies:
+ eslint: 9.39.2(jiti@2.6.1)
+ eslint-visitor-keys: 3.4.3
+
+ '@eslint-community/regexpp@4.12.2': {}
+
+ '@eslint/config-array@0.21.1':
+ dependencies:
+ '@eslint/object-schema': 2.1.7
+ debug: 4.4.3
+ minimatch: 3.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ '@eslint/config-helpers@0.4.2':
+ dependencies:
+ '@eslint/core': 0.17.0
+
+ '@eslint/core@0.17.0':
+ dependencies:
+ '@types/json-schema': 7.0.15
+
+ '@eslint/eslintrc@3.3.3':
+ dependencies:
+ ajv: 6.12.6
+ debug: 4.4.3
+ espree: 10.4.0
+ globals: 14.0.0
+ ignore: 5.3.2
+ import-fresh: 3.3.1
+ js-yaml: 4.1.1
+ minimatch: 3.1.2
+ strip-json-comments: 3.1.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@eslint/js@9.39.2': {}
+
+ '@eslint/object-schema@2.1.7': {}
+
+ '@eslint/plugin-kit@0.4.1':
+ dependencies:
+ '@eslint/core': 0.17.0
+ levn: 0.4.1
+
+ '@humanfs/core@0.19.1': {}
+
+ '@humanfs/node@0.16.7':
+ dependencies:
+ '@humanfs/core': 0.19.1
+ '@humanwhocodes/retry': 0.4.3
+
+ '@humanwhocodes/module-importer@1.0.1': {}
+
+ '@humanwhocodes/retry@0.4.3': {}
+
+ '@jridgewell/gen-mapping@0.3.13':
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/remapping@2.3.5':
+ dependencies:
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/resolve-uri@3.1.2': {}
+
+ '@jridgewell/sourcemap-codec@1.5.5': {}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ '@radix-ui/primitive@1.1.3': {}
+
+ '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.7)(react@19.2.3)':
+ dependencies:
+ react: 19.2.3
+ optionalDependencies:
+ '@types/react': 19.2.7
+
+ '@radix-ui/react-context@1.1.2(@types/react@19.2.7)(react@19.2.3)':
+ dependencies:
+ react: 19.2.3
+ optionalDependencies:
+ '@types/react': 19.2.7
+
+ '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.7)(react@19.2.3)
+ '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.3)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.2.3)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.3)
+ aria-hidden: 1.2.6
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ react-remove-scroll: 2.7.2(@types/react@19.2.7)(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.7
+ '@types/react-dom': 19.2.3(@types/react@19.2.7)
+
+ '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.3)
+ '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.7)(react@19.2.3)
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.7
+ '@types/react-dom': 19.2.3(@types/react@19.2.7)
+
+ '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.7)(react@19.2.3)':
+ dependencies:
+ react: 19.2.3
+ optionalDependencies:
+ '@types/react': 19.2.7
+
+ '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.3)
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.7
+ '@types/react-dom': 19.2.3(@types/react@19.2.7)
+
+ '@radix-ui/react-id@1.1.1(@types/react@19.2.7)(react@19.2.3)':
+ dependencies:
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.3)
+ react: 19.2.3
+ optionalDependencies:
+ '@types/react': 19.2.7
+
+ '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.3)
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.7
+ '@types/react-dom': 19.2.3(@types/react@19.2.7)
+
+ '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.3)
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.7
+ '@types/react-dom': 19.2.3(@types/react@19.2.7)
+
+ '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.2.3)
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.7
+ '@types/react-dom': 19.2.3(@types/react@19.2.7)
+
+ '@radix-ui/react-slot@1.2.3(@types/react@19.2.7)(react@19.2.3)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3)
+ react: 19.2.3
+ optionalDependencies:
+ '@types/react': 19.2.7
+
+ '@radix-ui/react-slot@1.2.4(@types/react@19.2.7)(react@19.2.3)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3)
+ react: 19.2.3
+ optionalDependencies:
+ '@types/react': 19.2.7
+
+ '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.7)(react@19.2.3)':
+ dependencies:
+ react: 19.2.3
+ optionalDependencies:
+ '@types/react': 19.2.7
+
+ '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.7)(react@19.2.3)':
+ dependencies:
+ '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.7)(react@19.2.3)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.3)
+ react: 19.2.3
+ optionalDependencies:
+ '@types/react': 19.2.7
+
+ '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.7)(react@19.2.3)':
+ dependencies:
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.3)
+ react: 19.2.3
+ optionalDependencies:
+ '@types/react': 19.2.7
+
+ '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.7)(react@19.2.3)':
+ dependencies:
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.3)
+ react: 19.2.3
+ optionalDependencies:
+ '@types/react': 19.2.7
+
+ '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.7)(react@19.2.3)':
+ dependencies:
+ react: 19.2.3
+ optionalDependencies:
+ '@types/react': 19.2.7
+
+ '@rolldown/pluginutils@1.0.0-beta.53': {}
+
+ '@rollup/rollup-android-arm-eabi@4.53.5':
+ optional: true
+
+ '@rollup/rollup-android-arm64@4.53.5':
+ optional: true
+
+ '@rollup/rollup-darwin-arm64@4.53.5':
+ optional: true
+
+ '@rollup/rollup-darwin-x64@4.53.5':
+ optional: true
+
+ '@rollup/rollup-freebsd-arm64@4.53.5':
+ optional: true
+
+ '@rollup/rollup-freebsd-x64@4.53.5':
+ optional: true
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.53.5':
+ optional: true
+
+ '@rollup/rollup-linux-arm-musleabihf@4.53.5':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-gnu@4.53.5':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-musl@4.53.5':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-gnu@4.53.5':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-gnu@4.53.5':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-gnu@4.53.5':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-musl@4.53.5':
+ optional: true
+
+ '@rollup/rollup-linux-s390x-gnu@4.53.5':
+ optional: true
+
+ '@rollup/rollup-linux-x64-gnu@4.53.5':
+ optional: true
+
+ '@rollup/rollup-linux-x64-musl@4.53.5':
+ optional: true
+
+ '@rollup/rollup-openharmony-arm64@4.53.5':
+ optional: true
+
+ '@rollup/rollup-win32-arm64-msvc@4.53.5':
+ optional: true
+
+ '@rollup/rollup-win32-ia32-msvc@4.53.5':
+ optional: true
+
+ '@rollup/rollup-win32-x64-gnu@4.53.5':
+ optional: true
+
+ '@rollup/rollup-win32-x64-msvc@4.53.5':
+ optional: true
+
+ '@tailwindcss/node@4.1.18':
+ dependencies:
+ '@jridgewell/remapping': 2.3.5
+ enhanced-resolve: 5.18.4
+ jiti: 2.6.1
+ lightningcss: 1.30.2
+ magic-string: 0.30.21
+ source-map-js: 1.2.1
+ tailwindcss: 4.1.18
+
+ '@tailwindcss/oxide-android-arm64@4.1.18':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-arm64@4.1.18':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-x64@4.1.18':
+ optional: true
+
+ '@tailwindcss/oxide-freebsd-x64@4.1.18':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.1.18':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.1.18':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.1.18':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-musl@4.1.18':
+ optional: true
+
+ '@tailwindcss/oxide-wasm32-wasi@4.1.18':
+ optional: true
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.1.18':
+ optional: true
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.1.18':
+ optional: true
+
+ '@tailwindcss/oxide@4.1.18':
+ optionalDependencies:
+ '@tailwindcss/oxide-android-arm64': 4.1.18
+ '@tailwindcss/oxide-darwin-arm64': 4.1.18
+ '@tailwindcss/oxide-darwin-x64': 4.1.18
+ '@tailwindcss/oxide-freebsd-x64': 4.1.18
+ '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.18
+ '@tailwindcss/oxide-linux-arm64-gnu': 4.1.18
+ '@tailwindcss/oxide-linux-arm64-musl': 4.1.18
+ '@tailwindcss/oxide-linux-x64-gnu': 4.1.18
+ '@tailwindcss/oxide-linux-x64-musl': 4.1.18
+ '@tailwindcss/oxide-wasm32-wasi': 4.1.18
+ '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18
+ '@tailwindcss/oxide-win32-x64-msvc': 4.1.18
+
+ '@tailwindcss/postcss@4.1.18':
+ dependencies:
+ '@alloc/quick-lru': 5.2.0
+ '@tailwindcss/node': 4.1.18
+ '@tailwindcss/oxide': 4.1.18
+ postcss: 8.5.6
+ tailwindcss: 4.1.18
+
+ '@tanstack/query-core@5.90.12': {}
+
+ '@tanstack/react-query@5.90.12(react@19.2.3)':
+ dependencies:
+ '@tanstack/query-core': 5.90.12
+ react: 19.2.3
+
+ '@types/babel__core@7.20.5':
+ dependencies:
+ '@babel/parser': 7.28.5
+ '@babel/types': 7.28.5
+ '@types/babel__generator': 7.27.0
+ '@types/babel__template': 7.4.4
+ '@types/babel__traverse': 7.28.0
+
+ '@types/babel__generator@7.27.0':
+ dependencies:
+ '@babel/types': 7.28.5
+
+ '@types/babel__template@7.4.4':
+ dependencies:
+ '@babel/parser': 7.28.5
+ '@babel/types': 7.28.5
+
+ '@types/babel__traverse@7.28.0':
+ dependencies:
+ '@babel/types': 7.28.5
+
+ '@types/estree@1.0.8': {}
+
+ '@types/json-schema@7.0.15': {}
+
+ '@types/node@25.0.3':
+ dependencies:
+ undici-types: 7.16.0
+
+ '@types/react-dom@19.2.3(@types/react@19.2.7)':
+ dependencies:
+ '@types/react': 19.2.7
+
+ '@types/react@19.2.7':
+ dependencies:
+ csstype: 3.2.3
+
+ '@typescript-eslint/eslint-plugin@8.50.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
+ dependencies:
+ '@eslint-community/regexpp': 4.12.2
+ '@typescript-eslint/parser': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/scope-manager': 8.50.0
+ '@typescript-eslint/type-utils': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.50.0
+ eslint: 9.39.2(jiti@2.6.1)
+ ignore: 7.0.5
+ natural-compare: 1.4.0
+ ts-api-utils: 2.1.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/scope-manager': 8.50.0
+ '@typescript-eslint/types': 8.50.0
+ '@typescript-eslint/typescript-estree': 8.50.0(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.50.0
+ debug: 4.4.3
+ eslint: 9.39.2(jiti@2.6.1)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/project-service@8.50.0(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/tsconfig-utils': 8.50.0(typescript@5.9.3)
+ '@typescript-eslint/types': 8.50.0
+ debug: 4.4.3
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/scope-manager@8.50.0':
+ dependencies:
+ '@typescript-eslint/types': 8.50.0
+ '@typescript-eslint/visitor-keys': 8.50.0
+
+ '@typescript-eslint/tsconfig-utils@8.50.0(typescript@5.9.3)':
+ dependencies:
+ typescript: 5.9.3
+
+ '@typescript-eslint/type-utils@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/types': 8.50.0
+ '@typescript-eslint/typescript-estree': 8.50.0(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
+ debug: 4.4.3
+ eslint: 9.39.2(jiti@2.6.1)
+ ts-api-utils: 2.1.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/types@8.50.0': {}
+
+ '@typescript-eslint/typescript-estree@8.50.0(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/project-service': 8.50.0(typescript@5.9.3)
+ '@typescript-eslint/tsconfig-utils': 8.50.0(typescript@5.9.3)
+ '@typescript-eslint/types': 8.50.0
+ '@typescript-eslint/visitor-keys': 8.50.0
+ debug: 4.4.3
+ minimatch: 9.0.5
+ semver: 7.7.3
+ tinyglobby: 0.2.15
+ ts-api-utils: 2.1.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/utils@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@2.6.1))
+ '@typescript-eslint/scope-manager': 8.50.0
+ '@typescript-eslint/types': 8.50.0
+ '@typescript-eslint/typescript-estree': 8.50.0(typescript@5.9.3)
+ eslint: 9.39.2(jiti@2.6.1)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/visitor-keys@8.50.0':
+ dependencies:
+ '@typescript-eslint/types': 8.50.0
+ eslint-visitor-keys: 4.2.1
+
+ '@vitejs/plugin-react@5.1.2(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2))':
+ dependencies:
+ '@babel/core': 7.28.5
+ '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5)
+ '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5)
+ '@rolldown/pluginutils': 1.0.0-beta.53
+ '@types/babel__core': 7.20.5
+ react-refresh: 0.18.0
+ vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)
+ transitivePeerDependencies:
+ - supports-color
+
+ acorn-jsx@5.3.2(acorn@8.15.0):
+ dependencies:
+ acorn: 8.15.0
+
+ acorn@8.15.0: {}
+
+ ajv@6.12.6:
+ dependencies:
+ fast-deep-equal: 3.1.3
+ fast-json-stable-stringify: 2.1.0
+ json-schema-traverse: 0.4.1
+ uri-js: 4.4.1
+
+ ansi-styles@4.3.0:
+ dependencies:
+ color-convert: 2.0.1
+
+ argparse@2.0.1: {}
+
+ aria-hidden@1.2.6:
+ dependencies:
+ tslib: 2.8.1
+
+ autoprefixer@10.4.23(postcss@8.5.6):
+ dependencies:
+ browserslist: 4.28.1
+ caniuse-lite: 1.0.30001761
+ fraction.js: 5.3.4
+ picocolors: 1.1.1
+ postcss: 8.5.6
+ postcss-value-parser: 4.2.0
+
+ balanced-match@1.0.2: {}
+
+ baseline-browser-mapping@2.9.11: {}
+
+ brace-expansion@1.1.12:
+ dependencies:
+ balanced-match: 1.0.2
+ concat-map: 0.0.1
+
+ brace-expansion@2.0.2:
+ dependencies:
+ balanced-match: 1.0.2
+
+ browserslist@4.28.1:
+ dependencies:
+ baseline-browser-mapping: 2.9.11
+ caniuse-lite: 1.0.30001761
+ electron-to-chromium: 1.5.267
+ node-releases: 2.0.27
+ update-browserslist-db: 1.2.3(browserslist@4.28.1)
+
+ callsites@3.1.0: {}
+
+ caniuse-lite@1.0.30001761: {}
+
+ chalk@4.1.2:
+ dependencies:
+ ansi-styles: 4.3.0
+ supports-color: 7.2.0
+
+ class-variance-authority@0.7.1:
+ dependencies:
+ clsx: 2.1.1
+
+ clsx@2.1.1: {}
+
+ color-convert@2.0.1:
+ dependencies:
+ color-name: 1.1.4
+
+ color-name@1.1.4: {}
+
+ concat-map@0.0.1: {}
+
+ convert-source-map@2.0.0: {}
+
+ cookie@1.1.1: {}
+
+ cross-spawn@7.0.6:
+ dependencies:
+ path-key: 3.1.1
+ shebang-command: 2.0.0
+ which: 2.0.2
+
+ csstype@3.2.3: {}
+
+ debug@4.4.3:
+ dependencies:
+ ms: 2.1.3
+
+ deep-is@0.1.4: {}
+
+ detect-libc@2.1.2: {}
+
+ detect-node-es@1.1.0: {}
+
+ electron-to-chromium@1.5.267: {}
+
+ enhanced-resolve@5.18.4:
+ dependencies:
+ graceful-fs: 4.2.11
+ tapable: 2.3.0
+
+ esbuild@0.27.2:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.27.2
+ '@esbuild/android-arm': 0.27.2
+ '@esbuild/android-arm64': 0.27.2
+ '@esbuild/android-x64': 0.27.2
+ '@esbuild/darwin-arm64': 0.27.2
+ '@esbuild/darwin-x64': 0.27.2
+ '@esbuild/freebsd-arm64': 0.27.2
+ '@esbuild/freebsd-x64': 0.27.2
+ '@esbuild/linux-arm': 0.27.2
+ '@esbuild/linux-arm64': 0.27.2
+ '@esbuild/linux-ia32': 0.27.2
+ '@esbuild/linux-loong64': 0.27.2
+ '@esbuild/linux-mips64el': 0.27.2
+ '@esbuild/linux-ppc64': 0.27.2
+ '@esbuild/linux-riscv64': 0.27.2
+ '@esbuild/linux-s390x': 0.27.2
+ '@esbuild/linux-x64': 0.27.2
+ '@esbuild/netbsd-arm64': 0.27.2
+ '@esbuild/netbsd-x64': 0.27.2
+ '@esbuild/openbsd-arm64': 0.27.2
+ '@esbuild/openbsd-x64': 0.27.2
+ '@esbuild/openharmony-arm64': 0.27.2
+ '@esbuild/sunos-x64': 0.27.2
+ '@esbuild/win32-arm64': 0.27.2
+ '@esbuild/win32-ia32': 0.27.2
+ '@esbuild/win32-x64': 0.27.2
+
+ escalade@3.2.0: {}
+
+ escape-string-regexp@4.0.0: {}
+
+ eslint-plugin-react-hooks@7.0.1(eslint@9.39.2(jiti@2.6.1)):
+ dependencies:
+ '@babel/core': 7.28.5
+ '@babel/parser': 7.28.5
+ eslint: 9.39.2(jiti@2.6.1)
+ hermes-parser: 0.25.1
+ zod: 4.2.1
+ zod-validation-error: 4.0.2(zod@4.2.1)
+ transitivePeerDependencies:
+ - supports-color
+
+ eslint-plugin-react-refresh@0.4.26(eslint@9.39.2(jiti@2.6.1)):
+ dependencies:
+ eslint: 9.39.2(jiti@2.6.1)
+
+ eslint-scope@8.4.0:
+ dependencies:
+ esrecurse: 4.3.0
+ estraverse: 5.3.0
+
+ eslint-visitor-keys@3.4.3: {}
+
+ eslint-visitor-keys@4.2.1: {}
+
+ eslint@9.39.2(jiti@2.6.1):
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@2.6.1))
+ '@eslint-community/regexpp': 4.12.2
+ '@eslint/config-array': 0.21.1
+ '@eslint/config-helpers': 0.4.2
+ '@eslint/core': 0.17.0
+ '@eslint/eslintrc': 3.3.3
+ '@eslint/js': 9.39.2
+ '@eslint/plugin-kit': 0.4.1
+ '@humanfs/node': 0.16.7
+ '@humanwhocodes/module-importer': 1.0.1
+ '@humanwhocodes/retry': 0.4.3
+ '@types/estree': 1.0.8
+ ajv: 6.12.6
+ chalk: 4.1.2
+ cross-spawn: 7.0.6
+ debug: 4.4.3
+ escape-string-regexp: 4.0.0
+ eslint-scope: 8.4.0
+ eslint-visitor-keys: 4.2.1
+ espree: 10.4.0
+ esquery: 1.6.0
+ esutils: 2.0.3
+ fast-deep-equal: 3.1.3
+ file-entry-cache: 8.0.0
+ find-up: 5.0.0
+ glob-parent: 6.0.2
+ ignore: 5.3.2
+ imurmurhash: 0.1.4
+ is-glob: 4.0.3
+ json-stable-stringify-without-jsonify: 1.0.1
+ lodash.merge: 4.6.2
+ minimatch: 3.1.2
+ natural-compare: 1.4.0
+ optionator: 0.9.4
+ optionalDependencies:
+ jiti: 2.6.1
+ transitivePeerDependencies:
+ - supports-color
+
+ espree@10.4.0:
+ dependencies:
+ acorn: 8.15.0
+ acorn-jsx: 5.3.2(acorn@8.15.0)
+ eslint-visitor-keys: 4.2.1
+
+ esquery@1.6.0:
+ dependencies:
+ estraverse: 5.3.0
+
+ esrecurse@4.3.0:
+ dependencies:
+ estraverse: 5.3.0
+
+ estraverse@5.3.0: {}
+
+ esutils@2.0.3: {}
+
+ fast-deep-equal@3.1.3: {}
+
+ fast-json-stable-stringify@2.1.0: {}
+
+ fast-levenshtein@2.0.6: {}
+
+ fdir@6.5.0(picomatch@4.0.3):
+ optionalDependencies:
+ picomatch: 4.0.3
+
+ file-entry-cache@8.0.0:
+ dependencies:
+ flat-cache: 4.0.1
+
+ find-up@5.0.0:
+ dependencies:
+ locate-path: 6.0.0
+ path-exists: 4.0.0
+
+ flat-cache@4.0.1:
+ dependencies:
+ flatted: 3.3.3
+ keyv: 4.5.4
+
+ flatted@3.3.3: {}
+
+ fraction.js@5.3.4: {}
+
+ fsevents@2.3.3:
+ optional: true
+
+ gensync@1.0.0-beta.2: {}
+
+ get-nonce@1.0.1: {}
+
+ glob-parent@6.0.2:
+ dependencies:
+ is-glob: 4.0.3
+
+ globals@14.0.0: {}
+
+ globals@16.5.0: {}
+
+ graceful-fs@4.2.11: {}
+
+ has-flag@4.0.0: {}
+
+ hermes-estree@0.25.1: {}
+
+ hermes-parser@0.25.1:
+ dependencies:
+ hermes-estree: 0.25.1
+
+ ignore@5.3.2: {}
+
+ ignore@7.0.5: {}
+
+ import-fresh@3.3.1:
+ dependencies:
+ parent-module: 1.0.1
+ resolve-from: 4.0.0
+
+ imurmurhash@0.1.4: {}
+
+ is-extglob@2.1.1: {}
+
+ is-glob@4.0.3:
+ dependencies:
+ is-extglob: 2.1.1
+
+ isexe@2.0.0: {}
+
+ jiti@2.6.1: {}
+
+ js-tokens@4.0.0: {}
+
+ js-yaml@4.1.1:
+ dependencies:
+ argparse: 2.0.1
+
+ jsesc@3.1.0: {}
+
+ json-buffer@3.0.1: {}
+
+ json-schema-traverse@0.4.1: {}
+
+ json-stable-stringify-without-jsonify@1.0.1: {}
+
+ json5@2.2.3: {}
+
+ keyv@4.5.4:
+ dependencies:
+ json-buffer: 3.0.1
+
+ levn@0.4.1:
+ dependencies:
+ prelude-ls: 1.2.1
+ type-check: 0.4.0
+
+ lightningcss-android-arm64@1.30.2:
+ optional: true
+
+ lightningcss-darwin-arm64@1.30.2:
+ optional: true
+
+ lightningcss-darwin-x64@1.30.2:
+ optional: true
+
+ lightningcss-freebsd-x64@1.30.2:
+ optional: true
+
+ lightningcss-linux-arm-gnueabihf@1.30.2:
+ optional: true
+
+ lightningcss-linux-arm64-gnu@1.30.2:
+ optional: true
+
+ lightningcss-linux-arm64-musl@1.30.2:
+ optional: true
+
+ lightningcss-linux-x64-gnu@1.30.2:
+ optional: true
+
+ lightningcss-linux-x64-musl@1.30.2:
+ optional: true
+
+ lightningcss-win32-arm64-msvc@1.30.2:
+ optional: true
+
+ lightningcss-win32-x64-msvc@1.30.2:
+ optional: true
+
+ lightningcss@1.30.2:
+ dependencies:
+ detect-libc: 2.1.2
+ optionalDependencies:
+ lightningcss-android-arm64: 1.30.2
+ lightningcss-darwin-arm64: 1.30.2
+ lightningcss-darwin-x64: 1.30.2
+ lightningcss-freebsd-x64: 1.30.2
+ lightningcss-linux-arm-gnueabihf: 1.30.2
+ lightningcss-linux-arm64-gnu: 1.30.2
+ lightningcss-linux-arm64-musl: 1.30.2
+ lightningcss-linux-x64-gnu: 1.30.2
+ lightningcss-linux-x64-musl: 1.30.2
+ lightningcss-win32-arm64-msvc: 1.30.2
+ lightningcss-win32-x64-msvc: 1.30.2
+
+ locate-path@6.0.0:
+ dependencies:
+ p-locate: 5.0.0
+
+ lodash.merge@4.6.2: {}
+
+ lru-cache@5.1.1:
+ dependencies:
+ yallist: 3.1.1
+
+ lucide-react@0.562.0(react@19.2.3):
+ dependencies:
+ react: 19.2.3
+
+ magic-string@0.30.21:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ minimatch@3.1.2:
+ dependencies:
+ brace-expansion: 1.1.12
+
+ minimatch@9.0.5:
+ dependencies:
+ brace-expansion: 2.0.2
+
+ ms@2.1.3: {}
+
+ nanoid@3.3.11: {}
+
+ natural-compare@1.4.0: {}
+
+ node-releases@2.0.27: {}
+
+ optionator@0.9.4:
+ dependencies:
+ deep-is: 0.1.4
+ fast-levenshtein: 2.0.6
+ levn: 0.4.1
+ prelude-ls: 1.2.1
+ type-check: 0.4.0
+ word-wrap: 1.2.5
+
+ p-limit@3.1.0:
+ dependencies:
+ yocto-queue: 0.1.0
+
+ p-locate@5.0.0:
+ dependencies:
+ p-limit: 3.1.0
+
+ parent-module@1.0.1:
+ dependencies:
+ callsites: 3.1.0
+
+ path-exists@4.0.0: {}
+
+ path-key@3.1.1: {}
+
+ picocolors@1.1.1: {}
+
+ picomatch@4.0.3: {}
+
+ postcss-value-parser@4.2.0: {}
+
+ postcss@8.5.6:
+ dependencies:
+ nanoid: 3.3.11
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ prelude-ls@1.2.1: {}
+
+ punycode@2.3.1: {}
+
+ react-dom@19.2.3(react@19.2.3):
+ dependencies:
+ react: 19.2.3
+ scheduler: 0.27.0
+
+ react-refresh@0.18.0: {}
+
+ react-remove-scroll-bar@2.3.8(@types/react@19.2.7)(react@19.2.3):
+ dependencies:
+ react: 19.2.3
+ react-style-singleton: 2.2.3(@types/react@19.2.7)(react@19.2.3)
+ tslib: 2.8.1
+ optionalDependencies:
+ '@types/react': 19.2.7
+
+ react-remove-scroll@2.7.2(@types/react@19.2.7)(react@19.2.3):
+ dependencies:
+ react: 19.2.3
+ react-remove-scroll-bar: 2.3.8(@types/react@19.2.7)(react@19.2.3)
+ react-style-singleton: 2.2.3(@types/react@19.2.7)(react@19.2.3)
+ tslib: 2.8.1
+ use-callback-ref: 1.3.3(@types/react@19.2.7)(react@19.2.3)
+ use-sidecar: 1.1.3(@types/react@19.2.7)(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.7
+
+ react-router-dom@7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
+ dependencies:
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ react-router: 7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+
+ react-router@7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
+ dependencies:
+ cookie: 1.1.1
+ react: 19.2.3
+ set-cookie-parser: 2.7.2
+ optionalDependencies:
+ react-dom: 19.2.3(react@19.2.3)
+
+ react-style-singleton@2.2.3(@types/react@19.2.7)(react@19.2.3):
+ dependencies:
+ get-nonce: 1.0.1
+ react: 19.2.3
+ tslib: 2.8.1
+ optionalDependencies:
+ '@types/react': 19.2.7
+
+ react@19.2.3: {}
+
+ resolve-from@4.0.0: {}
+
+ rollup@4.53.5:
+ dependencies:
+ '@types/estree': 1.0.8
+ optionalDependencies:
+ '@rollup/rollup-android-arm-eabi': 4.53.5
+ '@rollup/rollup-android-arm64': 4.53.5
+ '@rollup/rollup-darwin-arm64': 4.53.5
+ '@rollup/rollup-darwin-x64': 4.53.5
+ '@rollup/rollup-freebsd-arm64': 4.53.5
+ '@rollup/rollup-freebsd-x64': 4.53.5
+ '@rollup/rollup-linux-arm-gnueabihf': 4.53.5
+ '@rollup/rollup-linux-arm-musleabihf': 4.53.5
+ '@rollup/rollup-linux-arm64-gnu': 4.53.5
+ '@rollup/rollup-linux-arm64-musl': 4.53.5
+ '@rollup/rollup-linux-loong64-gnu': 4.53.5
+ '@rollup/rollup-linux-ppc64-gnu': 4.53.5
+ '@rollup/rollup-linux-riscv64-gnu': 4.53.5
+ '@rollup/rollup-linux-riscv64-musl': 4.53.5
+ '@rollup/rollup-linux-s390x-gnu': 4.53.5
+ '@rollup/rollup-linux-x64-gnu': 4.53.5
+ '@rollup/rollup-linux-x64-musl': 4.53.5
+ '@rollup/rollup-openharmony-arm64': 4.53.5
+ '@rollup/rollup-win32-arm64-msvc': 4.53.5
+ '@rollup/rollup-win32-ia32-msvc': 4.53.5
+ '@rollup/rollup-win32-x64-gnu': 4.53.5
+ '@rollup/rollup-win32-x64-msvc': 4.53.5
+ fsevents: 2.3.3
+
+ scheduler@0.27.0: {}
+
+ semver@6.3.1: {}
+
+ semver@7.7.3: {}
+
+ set-cookie-parser@2.7.2: {}
+
+ shebang-command@2.0.0:
+ dependencies:
+ shebang-regex: 3.0.0
+
+ shebang-regex@3.0.0: {}
+
+ source-map-js@1.2.1: {}
+
+ strip-json-comments@3.1.1: {}
+
+ supports-color@7.2.0:
+ dependencies:
+ has-flag: 4.0.0
+
+ tailwind-merge@3.4.0: {}
+
+ tailwindcss@4.1.18: {}
+
+ tapable@2.3.0: {}
+
+ tinyglobby@0.2.15:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.3)
+ picomatch: 4.0.3
+
+ ts-api-utils@2.1.0(typescript@5.9.3):
+ dependencies:
+ typescript: 5.9.3
+
+ tslib@2.8.1: {}
+
+ type-check@0.4.0:
+ dependencies:
+ prelude-ls: 1.2.1
+
+ typescript-eslint@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3):
+ dependencies:
+ '@typescript-eslint/eslint-plugin': 8.50.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/typescript-estree': 8.50.0(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
+ eslint: 9.39.2(jiti@2.6.1)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ typescript@5.9.3: {}
+
+ undici-types@7.16.0: {}
+
+ update-browserslist-db@1.2.3(browserslist@4.28.1):
+ dependencies:
+ browserslist: 4.28.1
+ escalade: 3.2.0
+ picocolors: 1.1.1
+
+ uri-js@4.4.1:
+ dependencies:
+ punycode: 2.3.1
+
+ use-callback-ref@1.3.3(@types/react@19.2.7)(react@19.2.3):
+ dependencies:
+ react: 19.2.3
+ tslib: 2.8.1
+ optionalDependencies:
+ '@types/react': 19.2.7
+
+ use-sidecar@1.1.3(@types/react@19.2.7)(react@19.2.3):
+ dependencies:
+ detect-node-es: 1.1.0
+ react: 19.2.3
+ tslib: 2.8.1
+ optionalDependencies:
+ '@types/react': 19.2.7
+
+ vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2):
+ dependencies:
+ esbuild: 0.27.2
+ fdir: 6.5.0(picomatch@4.0.3)
+ picomatch: 4.0.3
+ postcss: 8.5.6
+ rollup: 4.53.5
+ tinyglobby: 0.2.15
+ optionalDependencies:
+ '@types/node': 25.0.3
+ fsevents: 2.3.3
+ jiti: 2.6.1
+ lightningcss: 1.30.2
+
+ which@2.0.2:
+ dependencies:
+ isexe: 2.0.0
+
+ word-wrap@1.2.5: {}
+
+ yallist@3.1.1: {}
+
+ yocto-queue@0.1.0: {}
+
+ zod-validation-error@4.0.2(zod@4.2.1):
+ dependencies:
+ zod: 4.2.1
+
+ zod@4.2.1: {}
+
+ zustand@5.0.9(@types/react@19.2.7)(react@19.2.3):
+ optionalDependencies:
+ '@types/react': 19.2.7
+ react: 19.2.3
diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js
new file mode 100644
index 0000000..1c87846
--- /dev/null
+++ b/frontend/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ '@tailwindcss/postcss': {},
+ autoprefixer: {},
+ },
+}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
new file mode 100644
index 0000000..6f10319
--- /dev/null
+++ b/frontend/src/App.tsx
@@ -0,0 +1,87 @@
+import { useEffect } from 'react'
+import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { AppLayout } from './components/layout/AppLayout'
+import { Toaster } from './components/ui/toaster'
+import { useAuthStore } from './store/auth'
+import { Dashboard } from './pages/Dashboard'
+import { Channels } from './pages/Channels'
+import { Tokens } from './pages/Tokens'
+import { Pricing } from './pages/Pricing'
+import { ApiTest } from './pages/ApiTest'
+import { Database } from './pages/Database'
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: 1,
+ refetchOnWindowFocus: false,
+ },
+ },
+})
+
+function ProtectedRoute({ children }: { children: React.ReactNode }) {
+ const { isAuthenticated } = useAuthStore()
+
+ if (!isAuthenticated) {
+ return
+ }
+
+ return <>{children}>
+}
+
+function App() {
+ const { checkAuth } = useAuthStore()
+
+ useEffect(() => {
+ checkAuth()
+ }, [checkAuth])
+
+ return (
+
+
+
+
+ } />
+ } />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+
+
+ )
+}
+
+export default App
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
new file mode 100644
index 0000000..5243ef9
--- /dev/null
+++ b/frontend/src/api/client.ts
@@ -0,0 +1,181 @@
+import { ApiResponse } from '@/types'
+
+const BASE_URL = import.meta.env.VITE_API_BASE_URL || ''
+
+// 错误类型
+export class ApiError extends Error {
+ status: number
+
+ constructor(message: string, status: number) {
+ super(message)
+ this.name = 'ApiError'
+ this.status = status
+ }
+}
+
+// 全局错误处理回调
+let onUnauthorized: (() => void) | null = null
+let onError: ((error: ApiError) => void) | null = null
+
+export function setErrorHandlers(handlers: {
+ onUnauthorized?: () => void
+ onError?: (error: ApiError) => void
+}) {
+ onUnauthorized = handlers.onUnauthorized || null
+ onError = handlers.onError || null
+}
+
+// 请求拦截器
+async function requestInterceptor(config: RequestInit): Promise {
+ const headers = new Headers(config.headers)
+
+ // 设置默认 Content-Type
+ if (!headers.has('Content-Type')) {
+ headers.set('Content-Type', 'application/json')
+ }
+
+ // 添加 admin token
+ const adminToken = localStorage.getItem('adminToken')
+ if (adminToken) {
+ headers.set('x-admin-token', adminToken)
+ }
+
+ return { ...config, headers }
+}
+
+// 响应拦截器
+async function responseInterceptor(response: Response): Promise {
+ if (!response.ok) {
+ let errorMessage: string
+
+ try {
+ const errorData = await response.json()
+ errorMessage = errorData.message || errorData.error || JSON.stringify(errorData)
+ } catch {
+ errorMessage = await response.text() || `请求失败: ${response.status}`
+ }
+
+ const error = new ApiError(errorMessage, response.status)
+
+ // 处理特定状态码
+ switch (response.status) {
+ case 401:
+ // 未授权 - 清除 token 并触发回调
+ localStorage.removeItem('adminToken')
+ onUnauthorized?.()
+ error.message = '认证已过期,请重新登录'
+ break
+ case 403:
+ error.message = '没有权限执行此操作'
+ break
+ case 404:
+ error.message = '请求的资源不存在'
+ break
+ case 500:
+ error.message = '服务器内部错误'
+ break
+ case 502:
+ case 503:
+ case 504:
+ error.message = '服务暂时不可用,请稍后重试'
+ break
+ }
+
+ // 触发全局错误回调
+ onError?.(error)
+
+ throw error
+ }
+
+ const contentType = response.headers.get('content-type')
+ if (contentType && contentType.includes('application/json')) {
+ return response.json()
+ }
+
+ return response.text()
+}
+
+// 统一请求方法
+async function request(endpoint: string, options: RequestInit = {}): Promise {
+ const url = `${BASE_URL}${endpoint}`
+
+ try {
+ const config = await requestInterceptor(options)
+ const response = await fetch(url, config)
+ return responseInterceptor(response)
+ } catch (error) {
+ // 处理网络错误
+ if (error instanceof TypeError && error.message.includes('fetch')) {
+ const networkError = new ApiError('网络连接失败,请检查网络', 0)
+ onError?.(networkError)
+ throw networkError
+ }
+ throw error
+ }
+}
+
+// API 方法
+export const apiClient = {
+ // 通用方法
+ get: (endpoint: string) => request(endpoint, { method: 'GET' }),
+
+ post: (endpoint: string, data?: unknown) => request(endpoint, {
+ method: 'POST',
+ body: data ? JSON.stringify(data) : undefined,
+ }),
+
+ delete: (endpoint: string) => request(endpoint, { method: 'DELETE' }),
+
+ // Channel APIs
+ getChannels: () => request('/api/admin/channel', { method: 'GET' }),
+
+ saveChannel: (key: string, config: unknown) =>
+ request(`/api/admin/channel/${encodeURIComponent(key)}`, {
+ method: 'POST',
+ body: JSON.stringify(config),
+ }),
+
+ deleteChannel: (key: string) =>
+ request(`/api/admin/channel/${encodeURIComponent(key)}`, { method: 'DELETE' }),
+
+ // Token APIs
+ getTokens: () => request('/api/admin/token', { method: 'GET' }),
+
+ saveToken: (key: string, config: unknown) =>
+ request(`/api/admin/token/${encodeURIComponent(key)}`, {
+ method: 'POST',
+ body: JSON.stringify(config),
+ }),
+
+ deleteToken: (key: string) =>
+ request(`/api/admin/token/${encodeURIComponent(key)}`, { method: 'DELETE' }),
+
+ // Pricing APIs
+ getPricing: () => request('/api/admin/pricing', { method: 'GET' }),
+
+ savePricing: (config: unknown) =>
+ request('/api/admin/pricing', {
+ method: 'POST',
+ body: JSON.stringify(config),
+ }),
+
+ // Database APIs
+ initDatabase: () => request('/api/admin/db_initialize', { method: 'POST' }),
+
+ // API Test - 使用自定义 token,不走通用拦截器
+ testApi: async (endpoint: string, token: string, body: unknown) => {
+ const url = `${BASE_URL}${endpoint}`
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${token}`,
+ },
+ body: JSON.stringify(body),
+ })
+ return responseInterceptor(response)
+ },
+
+ // Auth check
+ checkAuth: () => request('/api/admin/channel', { method: 'GET' }),
+}
diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx
new file mode 100644
index 0000000..5999d7c
--- /dev/null
+++ b/frontend/src/components/layout/AppLayout.tsx
@@ -0,0 +1,101 @@
+import { ReactNode, useState } from 'react'
+import { Sidebar } from './Sidebar'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Button } from '@/components/ui/button'
+import { useAuthStore } from '@/store/auth'
+import { useToast } from '@/components/ui/use-toast'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+
+interface AppLayoutProps {
+ children: ReactNode
+}
+
+export function AppLayout({ children }: AppLayoutProps) {
+ const [adminToken, setAdminToken] = useState('')
+ const [authError, setAuthError] = useState('')
+ const [isLoading, setIsLoading] = useState(false)
+ const { login, showAuthModal, closeAuthModal, openAuthModal } = useAuthStore()
+ const { addToast } = useToast()
+
+ const handleAuthSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setAuthError('')
+ setIsLoading(true)
+
+ try {
+ await login(adminToken)
+ setAdminToken('')
+ addToast('登录成功', 'success')
+ } catch (error) {
+ setAuthError(error instanceof Error ? error.message : '管理员令牌无效')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return (
+
+
+
+
+ {children}
+
+
+ {/* Auth Dialog */}
+
+
+ )
+}
diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx
new file mode 100644
index 0000000..046bd98
--- /dev/null
+++ b/frontend/src/components/layout/Sidebar.tsx
@@ -0,0 +1,245 @@
+import { Link, useLocation } from 'react-router-dom'
+import { cn } from '@/lib/utils'
+import {
+ LayoutDashboard,
+ TestTube2,
+ Link as LinkIcon,
+ Key,
+ DollarSign,
+ Database,
+ Github,
+ Moon,
+ Sun,
+ LogIn,
+ LogOut,
+} from 'lucide-react'
+import { useAuthStore } from '@/store/auth'
+import { useState } from 'react'
+import { Button } from '../ui/button'
+
+interface NavItem {
+ title: string
+ href: string
+ icon: React.ComponentType<{ className?: string }>
+ adminOnly?: boolean
+}
+
+const navItems: NavItem[] = [
+ {
+ title: '仪表盘',
+ href: '/',
+ icon: LayoutDashboard,
+ },
+ {
+ title: 'API 测试',
+ href: '/api-test',
+ icon: TestTube2,
+ },
+ {
+ title: '频道管理',
+ href: '/channels',
+ icon: LinkIcon,
+ adminOnly: true,
+ },
+ {
+ title: '令牌管理',
+ href: '/tokens',
+ icon: Key,
+ adminOnly: true,
+ },
+ {
+ title: '定价管理',
+ href: '/pricing',
+ icon: DollarSign,
+ adminOnly: true,
+ },
+ {
+ title: '数据库',
+ href: '/database',
+ icon: Database,
+ adminOnly: true,
+ },
+]
+
+interface SidebarProps {
+ onAuthClick: () => void
+}
+
+export function Sidebar({ onAuthClick }: SidebarProps) {
+ const location = useLocation()
+ const { isAuthenticated, logout } = useAuthStore()
+ // Initialize theme from localStorage on mount
+ const [theme, setTheme] = useState<'light' | 'dark'>(() => {
+ if (typeof window !== 'undefined') {
+ const savedTheme = localStorage.getItem('theme')
+ const isDark = savedTheme === 'dark' || (!savedTheme && document.documentElement.classList.contains('dark'))
+ if (isDark) {
+ document.documentElement.classList.add('dark')
+ }
+ return isDark ? 'dark' : 'light'
+ }
+ return 'light'
+ })
+
+ const toggleTheme = () => {
+ const newTheme = theme === 'light' ? 'dark' : 'light'
+ setTheme(newTheme)
+ localStorage.setItem('theme', newTheme)
+ if (newTheme === 'dark') {
+ document.documentElement.classList.add('dark')
+ } else {
+ document.documentElement.classList.remove('dark')
+ }
+ }
+
+ const handleAuthClick = () => {
+ if (isAuthenticated) {
+ logout()
+ } else {
+ onAuthClick()
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/ui/alert.tsx b/frontend/src/components/ui/alert.tsx
new file mode 100644
index 0000000..1ed7254
--- /dev/null
+++ b/frontend/src/components/ui/alert.tsx
@@ -0,0 +1,55 @@
+import * as React from "react"
+import { cn } from "@/lib/utils"
+
+const Alert = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes & {
+ variant?: "default" | "destructive" | "warning" | "success"
+ }
+>(({ className, variant = "default", ...props }, ref) => (
+ svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&:has(svg)]:pl-11",
+ {
+ "bg-background text-foreground": variant === "default",
+ "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive":
+ variant === "destructive",
+ "border-orange-200/50 bg-transparent text-orange-400 dark:border-amber-700 dark:bg-amber-950/50 dark:text-amber-300 [&>svg]:text-orange-300":
+ variant === "warning",
+ "border-green-300 bg-green-50 text-green-800 dark:border-green-600 dark:bg-green-950 dark:text-green-200 [&>svg]:text-green-600":
+ variant === "success",
+ },
+ className
+ )}
+ {...props}
+ />
+))
+Alert.displayName = "Alert"
+
+const AlertTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+AlertTitle.displayName = "AlertTitle"
+
+const AlertDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+AlertDescription.displayName = "AlertDescription"
+
+export { Alert, AlertTitle, AlertDescription }
diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx
new file mode 100644
index 0000000..95d5579
--- /dev/null
+++ b/frontend/src/components/ui/badge.tsx
@@ -0,0 +1,39 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+ "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ destructive:
+ "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
+ outline: "text-foreground",
+ success:
+ "border-transparent bg-green-500 text-white hover:bg-green-600",
+ warning:
+ "border-transparent bg-yellow-500 text-white hover:bg-yellow-600",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+export interface BadgeProps
+ extends React.HTMLAttributes,
+ VariantProps {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx
new file mode 100644
index 0000000..0816a8f
--- /dev/null
+++ b/frontend/src/components/ui/button.tsx
@@ -0,0 +1,55 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
+ outline:
+ "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-10 px-4 py-2",
+ sm: "h-9 rounded-md px-3",
+ lg: "h-11 rounded-md px-8",
+ icon: "h-10 w-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button"
+ return (
+
+ )
+ }
+)
+Button.displayName = "Button"
+
+export { Button, buttonVariants }
diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx
new file mode 100644
index 0000000..5f0647f
--- /dev/null
+++ b/frontend/src/components/ui/card.tsx
@@ -0,0 +1,78 @@
+import * as React from "react"
+import { cn } from "@/lib/utils"
+
+const Card = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+Card.displayName = "Card"
+
+const CardHeader = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardHeader.displayName = "CardHeader"
+
+const CardTitle = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardTitle.displayName = "CardTitle"
+
+const CardDescription = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardDescription.displayName = "CardDescription"
+
+const CardContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardContent.displayName = "CardContent"
+
+const CardFooter = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardFooter.displayName = "CardFooter"
+
+export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
diff --git a/frontend/src/components/ui/checkbox.tsx b/frontend/src/components/ui/checkbox.tsx
new file mode 100644
index 0000000..05294cf
--- /dev/null
+++ b/frontend/src/components/ui/checkbox.tsx
@@ -0,0 +1,29 @@
+import * as React from "react"
+import { cn } from "@/lib/utils"
+
+export interface CheckboxProps extends React.InputHTMLAttributes {
+ onCheckedChange?: (checked: boolean) => void
+}
+
+const Checkbox = React.forwardRef(
+ ({ className, onCheckedChange, ...props }, ref) => {
+ return (
+ {
+ props.onChange?.(e)
+ onCheckedChange?.(e.target.checked)
+ }}
+ {...props}
+ />
+ )
+ }
+)
+Checkbox.displayName = "Checkbox"
+
+export { Checkbox }
diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..7326a43
--- /dev/null
+++ b/frontend/src/components/ui/dialog.tsx
@@ -0,0 +1,119 @@
+import * as React from "react"
+import * as DialogPrimitive from "@radix-ui/react-dialog"
+import { X } from "lucide-react"
+import { cn } from "@/lib/utils"
+
+const Dialog = DialogPrimitive.Root
+
+const DialogTrigger = DialogPrimitive.Trigger
+
+const DialogPortal = DialogPrimitive.Portal
+
+const DialogClose = DialogPrimitive.Close
+
+const DialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
+
+const DialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+))
+DialogContent.displayName = DialogPrimitive.Content.displayName
+
+const DialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+DialogHeader.displayName = "DialogHeader"
+
+const DialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+DialogFooter.displayName = "DialogFooter"
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogTitle.displayName = DialogPrimitive.Title.displayName
+
+const DialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogDescription.displayName = DialogPrimitive.Description.displayName
+
+export {
+ Dialog,
+ DialogPortal,
+ DialogOverlay,
+ DialogClose,
+ DialogTrigger,
+ DialogContent,
+ DialogHeader,
+ DialogFooter,
+ DialogTitle,
+ DialogDescription,
+}
diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx
new file mode 100644
index 0000000..562df6d
--- /dev/null
+++ b/frontend/src/components/ui/input.tsx
@@ -0,0 +1,21 @@
+import * as React from "react"
+import { cn } from "@/lib/utils"
+
+const Input = React.forwardRef>(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+ )
+ }
+)
+Input.displayName = "Input"
+
+export { Input }
diff --git a/frontend/src/components/ui/label.tsx b/frontend/src/components/ui/label.tsx
new file mode 100644
index 0000000..5efe4a9
--- /dev/null
+++ b/frontend/src/components/ui/label.tsx
@@ -0,0 +1,19 @@
+import * as React from "react"
+import { cn } from "@/lib/utils"
+
+const Label = React.forwardRef<
+ HTMLLabelElement,
+ React.ComponentProps<"label">
+>(({ className, ...props }, ref) => (
+
+))
+Label.displayName = "Label"
+
+export { Label }
diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx
new file mode 100644
index 0000000..27b852e
--- /dev/null
+++ b/frontend/src/components/ui/select.tsx
@@ -0,0 +1,21 @@
+import * as React from "react"
+import { cn } from "@/lib/utils"
+
+const Select = React.forwardRef<
+ HTMLSelectElement,
+ React.ComponentProps<"select">
+>(({ className, ...props }, ref) => {
+ return (
+
+ )
+})
+Select.displayName = "Select"
+
+export { Select }
diff --git a/frontend/src/components/ui/table.tsx b/frontend/src/components/ui/table.tsx
new file mode 100644
index 0000000..e9d5945
--- /dev/null
+++ b/frontend/src/components/ui/table.tsx
@@ -0,0 +1,116 @@
+import * as React from "react"
+import { cn } from "@/lib/utils"
+
+const Table = React.forwardRef<
+ HTMLTableElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+Table.displayName = "Table"
+
+const TableHeader = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+TableHeader.displayName = "TableHeader"
+
+const TableBody = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+TableBody.displayName = "TableBody"
+
+const TableFooter = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+ tr]:last:border-b-0",
+ className
+ )}
+ {...props}
+ />
+))
+TableFooter.displayName = "TableFooter"
+
+const TableRow = React.forwardRef<
+ HTMLTableRowElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+TableRow.displayName = "TableRow"
+
+const TableHead = React.forwardRef<
+ HTMLTableCellElement,
+ React.ThHTMLAttributes
+>(({ className, ...props }, ref) => (
+ |
+))
+TableHead.displayName = "TableHead"
+
+const TableCell = React.forwardRef<
+ HTMLTableCellElement,
+ React.TdHTMLAttributes
+>(({ className, ...props }, ref) => (
+ |
+))
+TableCell.displayName = "TableCell"
+
+const TableCaption = React.forwardRef<
+ HTMLTableCaptionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+TableCaption.displayName = "TableCaption"
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+}
diff --git a/frontend/src/components/ui/textarea.tsx b/frontend/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..1e33a83
--- /dev/null
+++ b/frontend/src/components/ui/textarea.tsx
@@ -0,0 +1,21 @@
+import * as React from "react"
+import { cn } from "@/lib/utils"
+
+const Textarea = React.forwardRef<
+ HTMLTextAreaElement,
+ React.ComponentProps<"textarea">
+>(({ className, ...props }, ref) => {
+ return (
+
+ )
+})
+Textarea.displayName = "Textarea"
+
+export { Textarea }
diff --git a/frontend/src/components/ui/toaster.tsx b/frontend/src/components/ui/toaster.tsx
new file mode 100644
index 0000000..e7897e2
--- /dev/null
+++ b/frontend/src/components/ui/toaster.tsx
@@ -0,0 +1,49 @@
+import { X, CheckCircle, AlertCircle, AlertTriangle, Info } from 'lucide-react'
+import { useToast } from './use-toast'
+import { cn } from '@/lib/utils'
+
+const toastStyles = {
+ success: 'border-green-400 bg-white text-green-700 dark:border-green-600 dark:bg-gray-900 dark:text-green-400',
+ error: 'border-red-400 bg-white text-red-700 dark:border-red-600 dark:bg-gray-900 dark:text-red-400',
+ warning: 'border-amber-400 bg-white text-amber-700 dark:border-amber-600 dark:bg-gray-900 dark:text-amber-400',
+ info: 'border-blue-400 bg-white text-blue-700 dark:border-blue-600 dark:bg-gray-900 dark:text-blue-400',
+}
+
+const toastIcons = {
+ success: CheckCircle,
+ error: AlertCircle,
+ warning: AlertTriangle,
+ info: Info,
+}
+
+export function Toaster() {
+ const { toasts, removeToast } = useToast()
+
+ return (
+
+ {toasts.map((toast) => {
+ const Icon = toastIcons[toast.type] || Info
+ return (
+
+
+
+
{toast.message}
+
+
+
+ )
+ })}
+
+ )
+}
diff --git a/frontend/src/components/ui/use-toast.ts b/frontend/src/components/ui/use-toast.ts
new file mode 100644
index 0000000..d67a4ff
--- /dev/null
+++ b/frontend/src/components/ui/use-toast.ts
@@ -0,0 +1,35 @@
+import { create } from 'zustand'
+
+type ToastType = 'success' | 'error' | 'info' | 'warning'
+
+interface Toast {
+ id: string
+ message: string
+ type: ToastType
+}
+
+interface ToastState {
+ toasts: Toast[]
+ addToast: (message: string, type: ToastType) => void
+ removeToast: (id: string) => void
+}
+
+export const useToast = create((set) => ({
+ toasts: [],
+ addToast: (message, type) => {
+ const id = Math.random().toString(36).substring(7)
+ set((state) => ({
+ toasts: [...state.toasts, { id, message, type }],
+ }))
+ // Auto remove after 5 seconds
+ setTimeout(() => {
+ set((state) => ({
+ toasts: state.toasts.filter((t) => t.id !== id),
+ }))
+ }, 5000)
+ },
+ removeToast: (id) =>
+ set((state) => ({
+ toasts: state.toasts.filter((t) => t.id !== id),
+ })),
+}))
diff --git a/frontend/src/counter.ts b/frontend/src/counter.ts
new file mode 100644
index 0000000..09e5afd
--- /dev/null
+++ b/frontend/src/counter.ts
@@ -0,0 +1,9 @@
+export function setupCounter(element: HTMLButtonElement) {
+ let counter = 0
+ const setCounter = (count: number) => {
+ counter = count
+ element.innerHTML = `count is ${counter}`
+ }
+ element.addEventListener('click', () => setCounter(counter + 1))
+ setCounter(0)
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 0000000..e77c88f
--- /dev/null
+++ b/frontend/src/index.css
@@ -0,0 +1,101 @@
+@import 'tailwindcss';
+
+@theme {
+ /* 现代简洁配色 - 粉色主题 */
+ --color-background: hsl(0 0% 100%);
+ --color-foreground: hsl(240 10% 4%);
+ --color-card: hsl(0 0% 100%);
+ --color-card-foreground: hsl(240 10% 4%);
+ --color-popover: hsl(0 0% 100%);
+ --color-popover-foreground: hsl(240 10% 4%);
+
+ /* 主色调 - 浅粉色 */
+ --color-primary: hsl(350 89% 70%);
+ --color-primary-foreground: hsl(0 0% 100%);
+
+ /* 次要色 */
+ --color-secondary: hsl(240 5% 96%);
+ --color-secondary-foreground: hsl(240 6% 10%);
+
+ /* 静音色 */
+ --color-muted: hsl(240 5% 96%);
+ --color-muted-foreground: hsl(240 4% 46%);
+
+ /* 强调色 - 淡粉 */
+ --color-accent: hsl(350 89% 95%);
+ --color-accent-foreground: hsl(350 70% 45%);
+
+ /* 危险色 */
+ --color-destructive: hsl(0 84% 60%);
+ --color-destructive-foreground: hsl(0 0% 100%);
+
+ /* 边框和输入框 */
+ --color-border: hsl(240 6% 90%);
+ --color-input: hsl(240 6% 90%);
+ --color-ring: hsl(350 89% 70%);
+
+ --radius: 0.5rem;
+}
+
+.dark {
+ --color-background: hsl(240 10% 4%);
+ --color-foreground: hsl(0 0% 95%);
+ --color-card: hsl(240 10% 4%);
+ --color-card-foreground: hsl(0 0% 95%);
+ --color-popover: hsl(240 10% 4%);
+ --color-popover-foreground: hsl(0 0% 95%);
+
+ /* 主色调 - 深色模式下的浅粉色 */
+ --color-primary: hsl(350 89% 75%);
+ --color-primary-foreground: hsl(240 10% 4%);
+
+ --color-secondary: hsl(240 4% 16%);
+ --color-secondary-foreground: hsl(0 0% 95%);
+
+ --color-muted: hsl(240 4% 16%);
+ --color-muted-foreground: hsl(240 5% 65%);
+
+ --color-accent: hsl(350 40% 18%);
+ --color-accent-foreground: hsl(350 89% 80%);
+
+ --color-destructive: hsl(0 63% 50%);
+ --color-destructive-foreground: hsl(0 0% 95%);
+
+ --color-border: hsl(240 4% 16%);
+ --color-input: hsl(240 4% 16%);
+ --color-ring: hsl(350 89% 75%);
+}
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground antialiased;
+ }
+}
+
+/* 自定义滚动条 */
+@layer utilities {
+ .scrollbar-thin {
+ scrollbar-width: thin;
+ }
+
+ .scrollbar-thin::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+ }
+
+ .scrollbar-thin::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ .scrollbar-thin::-webkit-scrollbar-thumb {
+ background: hsl(240 4% 46% / 0.3);
+ border-radius: 3px;
+ }
+
+ .scrollbar-thin::-webkit-scrollbar-thumb:hover {
+ background: hsl(240 4% 46% / 0.5);
+ }
+}
diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts
new file mode 100644
index 0000000..6850c0d
--- /dev/null
+++ b/frontend/src/lib/utils.ts
@@ -0,0 +1,30 @@
+import { type ClassValue, clsx } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
+
+export function formatCurrency(value: number): string {
+ return `$${(value / 1000000).toFixed(2)}`
+}
+
+export function formatQuota(value: number): string {
+ if (value >= 1000000) {
+ return `$${(value / 1000000).toFixed(2)}`
+ }
+ return value.toString()
+}
+
+export function copyToClipboard(text: string): Promise {
+ return navigator.clipboard.writeText(text)
+}
+
+export function generateTokenKey(): string {
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
+ let token = 'sk-'
+ for (let i = 0; i < 48; i++) {
+ token += chars.charAt(Math.floor(Math.random() * chars.length))
+ }
+ return token
+}
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
new file mode 100644
index 0000000..6396b50
--- /dev/null
+++ b/frontend/src/main.ts
@@ -0,0 +1,24 @@
+import './style.css'
+import typescriptLogo from './typescript.svg'
+import viteLogo from '/vite.svg'
+import { setupCounter } from './counter.ts'
+
+document.querySelector('#app')!.innerHTML = `
+
+
+
+
+
+
+
+
Vite + TypeScript
+
+
+
+
+ Click on the Vite and TypeScript logos to learn more
+
+
+`
+
+setupCounter(document.querySelector('#counter')!)
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..6f4ac9b
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import App from './App.tsx'
+import './index.css'
+
+createRoot(document.getElementById('root')!).render(
+
+
+ ,
+)
diff --git a/frontend/src/pages/ApiTest.tsx b/frontend/src/pages/ApiTest.tsx
new file mode 100644
index 0000000..0f88c00
--- /dev/null
+++ b/frontend/src/pages/ApiTest.tsx
@@ -0,0 +1,184 @@
+import { useState } from 'react'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import { Select } from '@/components/ui/select'
+import { Badge } from '@/components/ui/badge'
+import { useToast } from '@/components/ui/use-toast'
+import { apiClient } from '@/api/client'
+import { Send } from 'lucide-react'
+
+const requestTemplates: Record = {
+ '/v1/chat/completions': {
+ model: 'gpt-3.5-turbo',
+ messages: [
+ {
+ role: 'user',
+ content: 'Hello, how are you?',
+ },
+ ],
+ temperature: 0.7,
+ max_tokens: 100,
+ },
+ '/v1/messages': {
+ model: 'claude-3-5-sonnet-20241022',
+ max_tokens: 1024,
+ messages: [
+ {
+ role: 'user',
+ content: 'Hello, Claude!',
+ },
+ ],
+ },
+ '/v1/completions': {
+ model: 'gpt-3.5-turbo-instruct',
+ prompt: 'Once upon a time',
+ max_tokens: 100,
+ temperature: 0.7,
+ },
+}
+
+export function ApiTest() {
+ const [endpoint, setEndpoint] = useState('/v1/chat/completions')
+ const [apiToken, setApiToken] = useState('')
+ const [requestBody, setRequestBody] = useState(
+ JSON.stringify(requestTemplates['/v1/chat/completions'], null, 2)
+ )
+ const [isLoading, setIsLoading] = useState(false)
+ const [response, setResponse] = useState(null)
+ const [responseTime, setResponseTime] = useState(0)
+ const [statusCode, setStatusCode] = useState(null)
+
+ const { addToast } = useToast()
+
+ const handleEndpointChange = (newEndpoint: string) => {
+ setEndpoint(newEndpoint)
+ setRequestBody(JSON.stringify(requestTemplates[newEndpoint], null, 2))
+ }
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+
+ if (!apiToken) {
+ addToast('请输入API令牌', 'error')
+ return
+ }
+
+ let body: any
+ try {
+ body = JSON.parse(requestBody)
+ } catch (error) {
+ addToast('请求体JSON格式错误', 'error')
+ return
+ }
+
+ setIsLoading(true)
+ setResponse(null)
+ setStatusCode(null)
+
+ const startTime = Date.now()
+
+ try {
+ const result = await apiClient.testApi(endpoint, apiToken, body)
+ const endTime = Date.now()
+ setResponseTime(endTime - startTime)
+ setResponse(result)
+ setStatusCode(200)
+ addToast('请求成功', 'success')
+ } catch (error: any) {
+ const endTime = Date.now()
+ setResponseTime(endTime - startTime)
+ setStatusCode(error.status || 500)
+ setResponse({ error: error.message })
+ addToast('请求失败', 'error')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return (
+
+
+
API 测试
+
+
+
+
+ API 测试工具
+ 测试您的 API 连接和配置
+
+
+
+
+ {response && (
+
+
+
响应结果
+
+
+ {statusCode === 200 ? '成功' : '失败'}
+
+ {responseTime}ms
+
+
+
+
+
+ {JSON.stringify(response, null, 2)}
+
+
+
+
+ )}
+
+
+
+ )
+}
diff --git a/frontend/src/pages/Channels.tsx b/frontend/src/pages/Channels.tsx
new file mode 100644
index 0000000..b708a09
--- /dev/null
+++ b/frontend/src/pages/Channels.tsx
@@ -0,0 +1,459 @@
+import { useState } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { apiClient } from '@/api/client'
+import { Channel, ChannelConfig } from '@/types'
+import { Card, CardContent, CardHeader } from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Select } from '@/components/ui/select'
+import { Textarea } from '@/components/ui/textarea'
+import { useToast } from '@/components/ui/use-toast'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { Plus, RefreshCw, Pencil, Trash2, FileJson, FileText } from 'lucide-react'
+
+type EditMode = 'form' | 'json'
+
+export function Channels() {
+ const [view, setView] = useState<'list' | 'form'>('list')
+ const [editMode, setEditMode] = useState('form')
+ const [editingKey, setEditingKey] = useState(null)
+ const [formData, setFormData] = useState({
+ name: '',
+ type: 'azure-openai',
+ endpoint: '',
+ api_key: '',
+ api_version: '',
+ deployment_mapper: {},
+ })
+ const [channelKey, setChannelKey] = useState('')
+ const [jsonValue, setJsonValue] = useState('')
+ const [mapperRows, setMapperRows] = useState>([])
+
+ const { addToast } = useToast()
+ const queryClient = useQueryClient()
+
+ const { data, isLoading, refetch } = useQuery({
+ queryKey: ['channels'],
+ queryFn: async () => {
+ const response = await apiClient.getChannels()
+ return response.data as Channel[]
+ },
+ })
+
+ const saveMutation = useMutation({
+ mutationFn: async ({ key, config }: { key: string; config: any }) => {
+ return apiClient.saveChannel(key, config)
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['channels'] })
+ addToast(editingKey ? '频道更新成功' : '频道添加成功', 'success')
+ resetForm()
+ setView('list')
+ },
+ onError: (error: any) => {
+ addToast('保存失败:' + error.message, 'error')
+ },
+ })
+
+ const deleteMutation = useMutation({
+ mutationFn: async (key: string) => {
+ return apiClient.deleteChannel(key)
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['channels'] })
+ addToast('频道已删除', 'success')
+ },
+ onError: (error: any) => {
+ addToast('删除失败:' + error.message, 'error')
+ },
+ })
+
+ const resetForm = () => {
+ setFormData({
+ name: '',
+ type: 'azure-openai',
+ endpoint: '',
+ api_key: '',
+ api_version: '',
+ deployment_mapper: {},
+ })
+ setChannelKey('')
+ setJsonValue('')
+ setMapperRows([])
+ setEditingKey(null)
+ setEditMode('form')
+ }
+
+ const handleAdd = () => {
+ resetForm()
+ setView('form')
+ }
+
+ const handleEdit = (channel: Channel) => {
+ setEditingKey(channel.key)
+ setChannelKey(channel.key)
+ const config = typeof channel.value === 'string' ? JSON.parse(channel.value) : channel.value
+ setFormData(config)
+ setJsonValue(JSON.stringify(config, null, 2))
+
+ // Convert deployment_mapper to rows
+ if (config.deployment_mapper) {
+ const rows = Object.entries(config.deployment_mapper).map(([request, deployment]) => ({
+ request,
+ deployment: deployment as string,
+ }))
+ setMapperRows(rows)
+ }
+
+ setView('form')
+ }
+
+ const handleDelete = (key: string) => {
+ if (confirm(`确定要删除频道 "${key}" 吗?`)) {
+ deleteMutation.mutate(key)
+ }
+ }
+
+ const handleSave = () => {
+ if (!channelKey) {
+ addToast('请填写频道标识', 'error')
+ return
+ }
+
+ let config: any
+ if (editMode === 'form') {
+ if (!formData.name || !formData.endpoint || !formData.api_key) {
+ addToast('请填写所有必填字段', 'error')
+ return
+ }
+
+ // Build deployment_mapper from rows
+ const deployment_mapper: Record = {}
+ mapperRows.forEach((row) => {
+ if (row.request && row.deployment) {
+ deployment_mapper[row.request] = row.deployment
+ }
+ })
+
+ config = {
+ ...formData,
+ deployment_mapper,
+ }
+ if (!formData.api_version) {
+ delete config.api_version
+ }
+ } else {
+ try {
+ config = JSON.parse(jsonValue)
+ } catch (error) {
+ addToast('JSON格式错误', 'error')
+ return
+ }
+ }
+
+ saveMutation.mutate({ key: channelKey, config })
+ }
+
+ const toggleEditMode = () => {
+ if (editMode === 'form') {
+ // Switch to JSON
+ const deployment_mapper: Record = {}
+ mapperRows.forEach((row) => {
+ if (row.request && row.deployment) {
+ deployment_mapper[row.request] = row.deployment
+ }
+ })
+ const config = { ...formData, deployment_mapper }
+ setJsonValue(JSON.stringify(config, null, 2))
+ setEditMode('json')
+ } else {
+ // Switch to form
+ try {
+ const config = JSON.parse(jsonValue)
+ setFormData(config)
+ if (config.deployment_mapper) {
+ const rows = Object.entries(config.deployment_mapper).map(([request, deployment]) => ({
+ request,
+ deployment: deployment as string,
+ }))
+ setMapperRows(rows)
+ }
+ setEditMode('form')
+ } catch (error) {
+ addToast('JSON格式错误', 'error')
+ }
+ }
+ }
+
+ const addMapperRow = () => {
+ setMapperRows([...mapperRows, { request: '', deployment: '' }])
+ }
+
+ const removeMapperRow = (index: number) => {
+ setMapperRows(mapperRows.filter((_, i) => i !== index))
+ }
+
+ const updateMapperRow = (index: number, field: 'request' | 'deployment', value: string) => {
+ const newRows = [...mapperRows]
+ newRows[index][field] = value
+ setMapperRows(newRows)
+ }
+
+ if (view === 'list') {
+ return (
+
+
+
频道管理
+
+
+
+
+
+
+
+
+
+
+ {isLoading ? (
+ 加载中...
+ ) : !data || data.length === 0 ? (
+ 暂无频道数据
+ ) : (
+
+
+
+ 名称
+ 标识
+ 类型
+ 端点
+ 模型数
+ 操作
+
+
+
+ {data.map((channel) => {
+ const config =
+ typeof channel.value === 'string'
+ ? JSON.parse(channel.value)
+ : channel.value
+ return (
+
+ {config.name}
+ {channel.key}
+ {config.type}
+ {config.endpoint}
+
+ {Object.keys(config.deployment_mapper || {}).length}
+
+
+
+
+
+
+
+
+ )
+ })}
+
+
+ )}
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ {editingKey ? '编辑频道' : '添加频道'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setChannelKey(e.target.value)}
+ placeholder="例如: azure-openai-1"
+ disabled={!!editingKey}
+ />
+
+
+ {editMode === 'form' ? (
+ <>
+
+
+ setFormData({ ...formData, name: e.target.value })}
+ placeholder="例如: Azure OpenAI"
+ />
+
+
+
+
+
+
+
+
+
+ setFormData({ ...formData, endpoint: e.target.value })}
+ placeholder="https://your-resource.openai.azure.com/"
+ />
+
+
+
+
+ setFormData({ ...formData, api_key: e.target.value })}
+ placeholder="your-api-key"
+ />
+
+
+
+
+ setFormData({ ...formData, api_version: e.target.value })}
+ placeholder="例如: 2024-02-01"
+ />
+
+
+
+
+ {mapperRows.length === 0 ? (
+
+ 暂无映射,点击上方按钮添加
+
+ ) : (
+
+ )}
+
+ >
+ ) : (
+
+
+
+ )}
+
+
+
+ )
+}
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx
new file mode 100644
index 0000000..223aa60
--- /dev/null
+++ b/frontend/src/pages/Dashboard.tsx
@@ -0,0 +1,108 @@
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Globe, Gauge, BarChart3, Shield } from 'lucide-react'
+
+export function Dashboard() {
+ return (
+
+
+
仪表盘
+
+
+
+
+ 欢迎使用 Awsl One API
+ 统一的 AI 模型接口管理服务
+
+
+
+ Awsl One API 是一个强大的 API 代理和管理平台,为您提供统一的 AI 模型接口管理服务。
+
+
+
+
核心特性
+
+
+
+
+
+
+
多提供商支持
+
+ 支持 OpenAI、Azure OpenAI 和 Claude API,轻松切换不同的 AI 服务。
+
+
+
+
+
+
+
+
+
+
+
+
负载均衡
+
+ 自动在多个频道间分配请求,确保服务稳定性和高可用性。
+
+
+
+
+
+
+
+
+
+
+
+
用量追踪
+
+ 实时监控 token 使用情况,精确计算成本,防止超出配额。
+
+
+
+
+
+
+
+
+
+
+
+
安全管理
+
+ 基于令牌的访问控制,为不同用户分配独立的 API 密钥。
+
+
+
+
+
+
+
+
+
+
+
+
+ 快速开始
+ 按照以下步骤开始使用
+
+
+
+ -
+ 管理员登录:使用管理员令牌登录系统
+
+ -
+ 配置频道:添加 OpenAI、Azure 或 Claude API 频道
+
+ -
+ 创建令牌:为您的应用创建 API 令牌
+
+ -
+ 开始使用:使用生成的令牌调用 API
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/Database.tsx b/frontend/src/pages/Database.tsx
new file mode 100644
index 0000000..30de3b6
--- /dev/null
+++ b/frontend/src/pages/Database.tsx
@@ -0,0 +1,70 @@
+import { useState } from 'react'
+import { useMutation } from '@tanstack/react-query'
+import { apiClient } from '@/api/client'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { useToast } from '@/components/ui/use-toast'
+import { Database as DatabaseIcon, Info } from 'lucide-react'
+
+export function Database() {
+ const [result, setResult] = useState(null)
+ const { addToast } = useToast()
+
+ const initMutation = useMutation({
+ mutationFn: async () => {
+ return apiClient.initDatabase()
+ },
+ onSuccess: (data) => {
+ setResult(data)
+ addToast('数据库初始化成功', 'success')
+ },
+ onError: (error: any) => {
+ setResult({ error: error.message })
+ addToast('数据库初始化失败:' + error.message, 'error')
+ },
+ })
+
+ const handleInit = () => {
+ if (confirm('确定要初始化数据库吗?此操作将创建必要的表结构。')) {
+ initMutation.mutate()
+ }
+ }
+
+ return (
+
+
+
数据库管理
+
+
+
+
+
+ 数据库初始化
+
+
+
+
+
+
+ 初始化数据库将创建必要的表结构。如果表已存在,操作将被忽略。
+
+
+ {result && (
+
+
+
+
+ {JSON.stringify(result, null, 2)}
+
+
+
+
+ )}
+
+
+
+ )
+}
diff --git a/frontend/src/pages/Pricing.tsx b/frontend/src/pages/Pricing.tsx
new file mode 100644
index 0000000..b22051d
--- /dev/null
+++ b/frontend/src/pages/Pricing.tsx
@@ -0,0 +1,269 @@
+import { useState, useEffect } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { apiClient } from '@/api/client'
+import { PricingConfig } from '@/types'
+import { Card, CardContent, CardHeader } from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import { useToast } from '@/components/ui/use-toast'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { Plus, RefreshCw, Trash2, FileJson, FileText, Info } from 'lucide-react'
+
+type EditMode = 'table' | 'json'
+
+export function Pricing() {
+ const [editMode, setEditMode] = useState('table')
+ const [jsonValue, setJsonValue] = useState('')
+ const [pricingRows, setPricingRows] = useState<
+ Array<{ model: string; input: number; output: number }>
+ >([])
+ const [isInitialized, setIsInitialized] = useState(false)
+
+ const { addToast } = useToast()
+ const queryClient = useQueryClient()
+
+ const { data, isLoading, refetch } = useQuery({
+ queryKey: ['pricing'],
+ queryFn: async () => {
+ const response = await apiClient.getPricing()
+ return response.data as PricingConfig
+ },
+ })
+
+ // Initialize rows when data first loads - sync server state to local editable state
+ useEffect(() => {
+ if (data && !isInitialized) {
+ const rows = Object.entries(data).map(([model, pricing]) => ({
+ model,
+ input: pricing.input,
+ output: pricing.output,
+ }))
+ setPricingRows(rows)
+ setJsonValue(JSON.stringify(data, null, 2))
+ setIsInitialized(true)
+ }
+ }, [data, isInitialized])
+
+ const saveMutation = useMutation({
+ mutationFn: async (config: PricingConfig) => {
+ return apiClient.savePricing(config)
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['pricing'] })
+ addToast('定价配置已保存', 'success')
+ },
+ onError: (error: Error) => {
+ addToast('保存失败:' + error.message, 'error')
+ },
+ })
+
+ const handleSave = () => {
+ let config: PricingConfig
+
+ if (editMode === 'table') {
+ if (pricingRows.length === 0) {
+ addToast('请至少添加一个模型定价', 'error')
+ return
+ }
+
+ config = {}
+ pricingRows.forEach((row) => {
+ if (row.model) {
+ config[row.model] = {
+ input: row.input || 0,
+ output: row.output || 0,
+ }
+ }
+ })
+ } else {
+ try {
+ config = JSON.parse(jsonValue)
+ } catch (error) {
+ addToast('JSON格式错误', 'error')
+ return
+ }
+ }
+
+ saveMutation.mutate(config)
+ }
+
+ const toggleEditMode = () => {
+ if (editMode === 'table') {
+ // Switch to JSON
+ const config: PricingConfig = {}
+ pricingRows.forEach((row) => {
+ if (row.model) {
+ config[row.model] = {
+ input: row.input || 0,
+ output: row.output || 0,
+ }
+ }
+ })
+ setJsonValue(JSON.stringify(config, null, 2))
+ setEditMode('json')
+ } else {
+ // Switch to table
+ try {
+ const config = JSON.parse(jsonValue)
+ const rows = Object.entries(config).map(([model, pricing]: [string, unknown]) => ({
+ model,
+ input: (pricing as { input: number }).input || 0,
+ output: (pricing as { output: number }).output || 0,
+ }))
+ setPricingRows(rows)
+ setEditMode('table')
+ } catch (error) {
+ addToast('JSON格式错误', 'error')
+ }
+ }
+ }
+
+ const addRow = () => {
+ setPricingRows([...pricingRows, { model: '', input: 0, output: 0 }])
+ }
+
+ const removeRow = (index: number) => {
+ setPricingRows(pricingRows.filter((_, i) => i !== index))
+ }
+
+ const updateRow = (index: number, field: 'model' | 'input' | 'output', value: any) => {
+ const newRows = [...pricingRows]
+ newRows[index][field] = value
+ setPricingRows(newRows)
+ }
+
+ return (
+
+
+
定价管理
+
+
+
+
+
+
+ {editMode === 'table' && (
+
+ )}
+
+
+
+
+
+
+
+
+
+ 定价说明:
+ 基础配额单位:1百万token = $1.00 | 定价配置为不同模型相对于基础单位的倍率 |
+ 例如:gpt-4的input倍率为30,表示1百万token消耗30美元配额
+
+
+
+ {editMode === 'table' ? (
+ isLoading ? (
+ 加载中...
+ ) : pricingRows.length === 0 ? (
+
+ 暂无定价配置,点击"添加模型"按钮添加
+
+ ) : (
+
+ )
+ ) : (
+
+
+
+ )}
+
+
+
+ )
+}
diff --git a/frontend/src/pages/Tokens.tsx b/frontend/src/pages/Tokens.tsx
new file mode 100644
index 0000000..d8dfaae
--- /dev/null
+++ b/frontend/src/pages/Tokens.tsx
@@ -0,0 +1,453 @@
+import { useState, useEffect } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { apiClient } from '@/api/client'
+import { Token, TokenConfig, Channel } from '@/types'
+import { Card, CardContent, CardHeader } from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import { useToast } from '@/components/ui/use-toast'
+import { formatCurrency, copyToClipboard, generateTokenKey } from '@/lib/utils'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { Plus, RefreshCw, Pencil, Trash2, Copy, Dice5, FileJson, FileText } from 'lucide-react'
+import { Checkbox } from '@/components/ui/checkbox'
+
+type EditMode = 'form' | 'json'
+
+export function Tokens() {
+ const [view, setView] = useState<'list' | 'form'>('list')
+ const [editMode, setEditMode] = useState('form')
+ const [editingKey, setEditingKey] = useState(null)
+ const [formData, setFormData] = useState({
+ name: '',
+ channel_keys: [],
+ total_quota: 0,
+ })
+ const [tokenKey, setTokenKey] = useState('')
+ const [jsonValue, setJsonValue] = useState('')
+ const [availableChannels, setAvailableChannels] = useState([])
+ const [selectedChannels, setSelectedChannels] = useState([])
+
+ const { addToast } = useToast()
+ const queryClient = useQueryClient()
+
+ const { data, isLoading, refetch } = useQuery({
+ queryKey: ['tokens'],
+ queryFn: async () => {
+ const response = await apiClient.getTokens()
+ return response.data as Token[]
+ },
+ })
+
+ // Load available channels
+ useEffect(() => {
+ const loadChannels = async () => {
+ try {
+ const response = await apiClient.getChannels()
+ const channels = response.data as Channel[]
+ setAvailableChannels(channels.map((c) => c.key))
+ } catch (error) {
+ console.error('Failed to load channels:', error)
+ }
+ }
+ loadChannels()
+ }, [])
+
+ const saveMutation = useMutation({
+ mutationFn: async ({ key, config }: { key: string; config: any }) => {
+ return apiClient.saveToken(key, config)
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['tokens'] })
+ addToast(editingKey ? '令牌更新成功' : '令牌添加成功', 'success')
+ resetForm()
+ setView('list')
+ },
+ onError: (error: any) => {
+ addToast('保存失败:' + error.message, 'error')
+ },
+ })
+
+ const deleteMutation = useMutation({
+ mutationFn: async (key: string) => {
+ return apiClient.deleteToken(key)
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['tokens'] })
+ addToast('令牌已删除', 'success')
+ },
+ onError: (error: any) => {
+ addToast('删除失败:' + error.message, 'error')
+ },
+ })
+
+ const resetForm = () => {
+ setFormData({
+ name: '',
+ channel_keys: [],
+ total_quota: 0,
+ })
+ setTokenKey('')
+ setJsonValue('')
+ setSelectedChannels([])
+ setEditingKey(null)
+ setEditMode('form')
+ }
+
+ const handleAdd = () => {
+ resetForm()
+ setTokenKey(generateTokenKey())
+ setView('form')
+ }
+
+ const handleEdit = (token: Token) => {
+ setEditingKey(token.key)
+ setTokenKey(token.key)
+ const config = typeof token.value === 'string' ? JSON.parse(token.value) : token.value
+ setFormData(config)
+ setJsonValue(JSON.stringify(config, null, 2))
+ setSelectedChannels(config.channel_keys || [])
+ setView('form')
+ }
+
+ const handleDelete = (key: string) => {
+ if (confirm(`确定要删除令牌 "${key}" 吗?`)) {
+ deleteMutation.mutate(key)
+ }
+ }
+
+ const handleCopy = async (text: string) => {
+ try {
+ await copyToClipboard(text)
+ addToast('已复制到剪贴板', 'success')
+ } catch (error) {
+ addToast('复制失败', 'error')
+ }
+ }
+
+ const handleSave = () => {
+ if (!tokenKey) {
+ addToast('请填写令牌标识', 'error')
+ return
+ }
+
+ let config: any
+ if (editMode === 'form') {
+ if (!formData.name) {
+ addToast('请填写令牌名称', 'error')
+ return
+ }
+
+ config = {
+ ...formData,
+ channel_keys: selectedChannels,
+ }
+ } else {
+ try {
+ config = JSON.parse(jsonValue)
+ } catch (error) {
+ addToast('JSON格式错误', 'error')
+ return
+ }
+ }
+
+ saveMutation.mutate({ key: tokenKey, config })
+ }
+
+ const toggleEditMode = () => {
+ if (editMode === 'form') {
+ const config = { ...formData, channel_keys: selectedChannels }
+ setJsonValue(JSON.stringify(config, null, 2))
+ setEditMode('json')
+ } else {
+ try {
+ const config = JSON.parse(jsonValue)
+ setFormData(config)
+ setSelectedChannels(config.channel_keys || [])
+ setEditMode('form')
+ } catch (error) {
+ addToast('JSON格式错误', 'error')
+ }
+ }
+ }
+
+ const toggleChannel = (channelKey: string) => {
+ setSelectedChannels((prev) =>
+ prev.includes(channelKey)
+ ? prev.filter((k) => k !== channelKey)
+ : [...prev, channelKey]
+ )
+ }
+
+ const setQuotaPreset = (value: number) => {
+ setFormData({ ...formData, total_quota: value })
+ }
+
+ if (view === 'list') {
+ return (
+
+
+
令牌管理
+
+
+
+
+
+
+
+
+
+
+ {isLoading ? (
+ 加载中...
+ ) : !data || data.length === 0 ? (
+ 暂无令牌数据
+ ) : (
+
+
+
+ 名称
+ 标识
+ 频道
+ 配额
+ 已用
+ 操作
+
+
+
+ {data.map((token) => {
+ const config =
+ typeof token.value === 'string' ? JSON.parse(token.value) : token.value
+ const channelKeys = config.channel_keys || []
+ const channelDisplay =
+ channelKeys.length === 0
+ ? '所有频道'
+ : channelKeys.length > 2
+ ? `${channelKeys.slice(0, 2).join(', ')}...`
+ : channelKeys.join(', ')
+
+ return (
+
+ {config.name}
+
+ {token.key.slice(0, 8)}...{token.key.slice(-4)}
+
+ {channelDisplay}
+ {formatCurrency(config.total_quota || 0)}
+ {formatCurrency(token.usage || 0)}
+
+
+
+
+
+
+
+
+ )
+ })}
+
+
+ )}
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ {editingKey ? '编辑令牌' : '添加令牌'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setTokenKey(e.target.value)}
+ placeholder="输入令牌标识或点击生成"
+ disabled={!!editingKey}
+ className="flex-1"
+ />
+ {!editingKey && (
+
+ )}
+
+
+
+ {editMode === 'form' ? (
+ <>
+
+
+ setFormData({ ...formData, name: e.target.value })}
+ placeholder="例如: 用户令牌1"
+ />
+
+
+
+
+
+ {availableChannels.length === 0 ? (
+
暂无可用频道
+ ) : (
+ availableChannels.map((channelKey) => (
+
+ toggleChannel(channelKey)}
+ />
+
+
+ ))
+ )}
+
+
不选表示允许访问所有频道
+
+
+
+
+
+ setFormData({ ...formData, total_quota: parseInt(e.target.value) || 0 })
+ }
+ placeholder="1000000"
+ min="0"
+ step="1"
+ />
+
+
快速选择:
+
+
+
+
+
+
+
+
+
+ 设置令牌的总使用配额 (基础单位:1百万token = $1.00)
+
+
+ >
+ ) : (
+
+
+
+ )}
+
+
+
+ )
+}
diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts
new file mode 100644
index 0000000..cb65d4d
--- /dev/null
+++ b/frontend/src/store/auth.ts
@@ -0,0 +1,66 @@
+import { create } from 'zustand'
+import { apiClient } from '@/api/client'
+
+interface AuthState {
+ isAuthenticated: boolean
+ isLoading: boolean
+ error: string | null
+ showAuthModal: boolean
+ login: (token: string) => Promise
+ logout: () => void
+ checkAuth: () => Promise
+ openAuthModal: () => void
+ closeAuthModal: () => void
+}
+
+export const useAuthStore = create((set) => ({
+ isAuthenticated: false,
+ isLoading: false,
+ error: null,
+ showAuthModal: false,
+
+ login: async (token: string) => {
+ set({ isLoading: true, error: null })
+ try {
+ // Store token
+ localStorage.setItem('adminToken', token)
+
+ // Verify token by making a test request
+ await apiClient.checkAuth()
+
+ set({ isAuthenticated: true, isLoading: false, showAuthModal: false })
+ } catch (error) {
+ localStorage.removeItem('adminToken')
+ set({
+ isAuthenticated: false,
+ isLoading: false,
+ error: error instanceof Error ? error.message : 'Authentication failed',
+ })
+ throw error
+ }
+ },
+
+ logout: () => {
+ localStorage.removeItem('adminToken')
+ set({ isAuthenticated: false, error: null })
+ },
+
+ checkAuth: async () => {
+ const token = localStorage.getItem('adminToken')
+ if (!token) {
+ set({ isAuthenticated: false })
+ return
+ }
+
+ try {
+ await apiClient.checkAuth()
+ set({ isAuthenticated: true })
+ } catch (error) {
+ localStorage.removeItem('adminToken')
+ set({ isAuthenticated: false })
+ }
+ },
+
+ openAuthModal: () => set({ showAuthModal: true }),
+ closeAuthModal: () => set({ showAuthModal: false }),
+}))
diff --git a/frontend/src/style.css b/frontend/src/style.css
new file mode 100644
index 0000000..3bcdbd0
--- /dev/null
+++ b/frontend/src/style.css
@@ -0,0 +1,96 @@
+:root {
+ font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ color-scheme: light dark;
+ color: rgba(255, 255, 255, 0.87);
+ background-color: #242424;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+a {
+ font-weight: 500;
+ color: #646cff;
+ text-decoration: inherit;
+}
+a:hover {
+ color: #535bf2;
+}
+
+body {
+ margin: 0;
+ display: flex;
+ place-items: center;
+ min-width: 320px;
+ min-height: 100vh;
+}
+
+h1 {
+ font-size: 3.2em;
+ line-height: 1.1;
+}
+
+#app {
+ max-width: 1280px;
+ margin: 0 auto;
+ padding: 2rem;
+ text-align: center;
+}
+
+.logo {
+ height: 6em;
+ padding: 1.5em;
+ will-change: filter;
+ transition: filter 300ms;
+}
+.logo:hover {
+ filter: drop-shadow(0 0 2em #646cffaa);
+}
+.logo.vanilla:hover {
+ filter: drop-shadow(0 0 2em #3178c6aa);
+}
+
+.card {
+ padding: 2em;
+}
+
+.read-the-docs {
+ color: #888;
+}
+
+button {
+ border-radius: 8px;
+ border: 1px solid transparent;
+ padding: 0.6em 1.2em;
+ font-size: 1em;
+ font-weight: 500;
+ font-family: inherit;
+ background-color: #1a1a1a;
+ cursor: pointer;
+ transition: border-color 0.25s;
+}
+button:hover {
+ border-color: #646cff;
+}
+button:focus,
+button:focus-visible {
+ outline: 4px auto -webkit-focus-ring-color;
+}
+
+@media (prefers-color-scheme: light) {
+ :root {
+ color: #213547;
+ background-color: #ffffff;
+ }
+ a:hover {
+ color: #747bff;
+ }
+ button {
+ background-color: #f9f9f9;
+ }
+}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
new file mode 100644
index 0000000..efb0ad3
--- /dev/null
+++ b/frontend/src/types/index.ts
@@ -0,0 +1,68 @@
+export interface Channel {
+ key: string
+ value: string | ChannelConfig
+ usage?: number
+}
+
+export interface ChannelConfig {
+ name: string
+ type: 'openai' | 'azure-openai' | 'claude'
+ endpoint: string
+ api_key: string
+ api_version?: string
+ deployment_mapper?: Record
+}
+
+export interface Token {
+ key: string
+ value: string | TokenConfig
+ usage?: number
+}
+
+export interface TokenConfig {
+ name: string
+ channel_keys?: string[]
+ total_quota: number
+}
+
+export interface PricingModel {
+ input: number
+ output: number
+}
+
+export type PricingConfig = Record
+
+export interface ApiResponse {
+ data?: T
+ error?: string
+ message?: string
+}
+
+export interface TestRequest {
+ model: string
+ messages?: Array<{
+ role: string
+ content: string
+ }>
+ prompt?: string
+ max_tokens?: number
+ temperature?: number
+ stream?: boolean
+}
+
+export interface TestResponse {
+ id?: string
+ model?: string
+ choices?: Array<{
+ message?: {
+ role: string
+ content: string
+ }
+ text?: string
+ }>
+ usage?: {
+ prompt_tokens?: number
+ completion_tokens?: number
+ total_tokens?: number
+ }
+}
diff --git a/frontend/src/typescript.svg b/frontend/src/typescript.svg
new file mode 100644
index 0000000..d91c910
--- /dev/null
+++ b/frontend/src/typescript.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
new file mode 100644
index 0000000..8ab4f00
--- /dev/null
+++ b/frontend/src/vite-env.d.ts
@@ -0,0 +1,9 @@
+///
+
+interface ImportMetaEnv {
+ readonly VITE_API_BASE_URL: string
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv
+}
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
new file mode 100644
index 0000000..098ecc8
--- /dev/null
+++ b/frontend/tailwind.config.js
@@ -0,0 +1,76 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ darkMode: ["class"],
+ content: [
+ './pages/**/*.{ts,tsx}',
+ './components/**/*.{ts,tsx}',
+ './app/**/*.{ts,tsx}',
+ './src/**/*.{ts,tsx}',
+ ],
+ theme: {
+ container: {
+ center: true,
+ padding: "2rem",
+ screens: {
+ "2xl": "1400px",
+ },
+ },
+ extend: {
+ colors: {
+ border: "hsl(var(--border))",
+ input: "hsl(var(--input))",
+ ring: "hsl(var(--ring))",
+ background: "hsl(var(--background))",
+ foreground: "hsl(var(--foreground))",
+ primary: {
+ DEFAULT: "hsl(var(--primary))",
+ foreground: "hsl(var(--primary-foreground))",
+ },
+ secondary: {
+ DEFAULT: "hsl(var(--secondary))",
+ foreground: "hsl(var(--secondary-foreground))",
+ },
+ destructive: {
+ DEFAULT: "hsl(var(--destructive))",
+ foreground: "hsl(var(--destructive-foreground))",
+ },
+ muted: {
+ DEFAULT: "hsl(var(--muted))",
+ foreground: "hsl(var(--muted-foreground))",
+ },
+ accent: {
+ DEFAULT: "hsl(var(--accent))",
+ foreground: "hsl(var(--accent-foreground))",
+ },
+ popover: {
+ DEFAULT: "hsl(var(--popover))",
+ foreground: "hsl(var(--popover-foreground))",
+ },
+ card: {
+ DEFAULT: "hsl(var(--card))",
+ foreground: "hsl(var(--card-foreground))",
+ },
+ },
+ borderRadius: {
+ lg: "var(--radius)",
+ md: "calc(var(--radius) - 2px)",
+ sm: "calc(var(--radius) - 4px)",
+ },
+ keyframes: {
+ "accordion-down": {
+ from: { height: "0" },
+ to: { height: "var(--radix-accordion-content-height)" },
+ },
+ "accordion-up": {
+ from: { height: "var(--radix-accordion-content-height)" },
+ to: { height: "0" },
+ },
+ },
+ animation: {
+ "accordion-down": "accordion-down 0.2s ease-out",
+ "accordion-up": "accordion-up 0.2s ease-out",
+ },
+ },
+ },
+ plugins: [],
+}
diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts
new file mode 100644
index 0000000..2322185
--- /dev/null
+++ b/frontend/tailwind.config.ts
@@ -0,0 +1,54 @@
+import type { Config } from "tailwindcss";
+
+export default {
+ darkMode: ["class"],
+ content: [
+ "./index.html",
+ "./src/**/*.{js,ts,jsx,tsx}",
+ ],
+ theme: {
+ extend: {
+ borderRadius: {
+ lg: "var(--radius)",
+ md: "calc(var(--radius) - 2px)",
+ sm: "calc(var(--radius) - 4px)",
+ },
+ colors: {
+ background: "hsl(var(--background))",
+ foreground: "hsl(var(--foreground))",
+ card: {
+ DEFAULT: "hsl(var(--card))",
+ foreground: "hsl(var(--card-foreground))",
+ },
+ popover: {
+ DEFAULT: "hsl(var(--popover))",
+ foreground: "hsl(var(--popover-foreground))",
+ },
+ primary: {
+ DEFAULT: "hsl(var(--primary))",
+ foreground: "hsl(var(--primary-foreground))",
+ },
+ secondary: {
+ DEFAULT: "hsl(var(--secondary))",
+ foreground: "hsl(var(--secondary-foreground))",
+ },
+ muted: {
+ DEFAULT: "hsl(var(--muted))",
+ foreground: "hsl(var(--muted-foreground))",
+ },
+ accent: {
+ DEFAULT: "hsl(var(--accent))",
+ foreground: "hsl(var(--accent-foreground))",
+ },
+ destructive: {
+ DEFAULT: "hsl(var(--destructive))",
+ foreground: "hsl(var(--destructive-foreground))",
+ },
+ border: "hsl(var(--border))",
+ input: "hsl(var(--input))",
+ ring: "hsl(var(--ring))",
+ },
+ },
+ },
+ plugins: [],
+} satisfies Config;
diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json
new file mode 100644
index 0000000..6d9e0f0
--- /dev/null
+++ b/frontend/tsconfig.app.json
@@ -0,0 +1,31 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true,
+
+ /* Path mapping */
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["src"]
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..fec8c8e
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ],
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ }
+}
diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json
new file mode 100644
index 0000000..9724199
--- /dev/null
+++ b/frontend/tsconfig.node.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..5cd7f44
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,17 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import path from 'path'
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+ build: {
+ outDir: '../public',
+ emptyOutDir: true,
+ },
+})
diff --git a/package.json b/package.json
index ad84ea9..af9ef2d 100644
--- a/package.json
+++ b/package.json
@@ -7,11 +7,11 @@
},
"dependencies": {
"chanfana": "^2.8.3",
- "hono": "^4.10.8",
+ "hono": "^4.11.1",
"zod": "^3.25.76"
},
"devDependencies": {
- "wrangler": "^4.54.0"
+ "wrangler": "^4.56.0"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 52f58ca..627af2a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -12,15 +12,15 @@ importers:
specifier: ^2.8.3
version: 2.8.3
hono:
- specifier: ^4.10.8
- version: 4.10.8
+ specifier: ^4.11.1
+ version: 4.11.1
zod:
specifier: ^3.25.76
version: 3.25.76
devDependencies:
wrangler:
- specifier: ^4.54.0
- version: 4.54.0
+ specifier: ^4.56.0
+ version: 4.56.0
packages:
@@ -42,32 +42,32 @@ packages:
workerd:
optional: true
- '@cloudflare/workerd-darwin-64@1.20251210.0':
- resolution: {integrity: sha512-Nn9X1moUDERA9xtFdCQ2XpQXgAS9pOjiCxvOT8sVx9UJLAiBLkfSCGbpsYdarODGybXCpjRlc77Yppuolvt7oQ==}
+ '@cloudflare/workerd-darwin-64@1.20251217.0':
+ resolution: {integrity: sha512-DN6vT+9ho61d/1/YuILW4VS+N1JBLaixWRL1vqNmhgbf8J8VHwWWotrRruEUYigJKx2yZyw6YsasE+yLXgx/Fw==}
engines: {node: '>=16'}
cpu: [x64]
os: [darwin]
- '@cloudflare/workerd-darwin-arm64@1.20251210.0':
- resolution: {integrity: sha512-Mg8iYIZQFnbevq/ls9eW/eneWTk/EE13Pej1MwfkY5et0jVpdHnvOLywy/o+QtMJFef1AjsqXGULwAneYyBfHw==}
+ '@cloudflare/workerd-darwin-arm64@1.20251217.0':
+ resolution: {integrity: sha512-5nZOpRTkHmtcTc4Wbr1mj/O3dLb6aHZSiJuVBgtdbVcVmOXueSay3hnw1PXEyR+vpTKGUPkM+omUIslKHWnXDw==}
engines: {node: '>=16'}
cpu: [arm64]
os: [darwin]
- '@cloudflare/workerd-linux-64@1.20251210.0':
- resolution: {integrity: sha512-kjC2fCZhZ2Gkm1biwk2qByAYpGguK5Gf5ic8owzSCUw0FOUfQxTZUT9Lp3gApxsfTLbbnLBrX/xzWjywH9QR4g==}
+ '@cloudflare/workerd-linux-64@1.20251217.0':
+ resolution: {integrity: sha512-uoPGhMaZVXPpCsU0oG3HQzyVpXCGi5rU+jcHRjUI7DXM4EwctBGvZ380Knkja36qtl+ZvSKVR1pUFSGdK+45Pg==}
engines: {node: '>=16'}
cpu: [x64]
os: [linux]
- '@cloudflare/workerd-linux-arm64@1.20251210.0':
- resolution: {integrity: sha512-2IB37nXi7PZVQLa1OCuO7/6pNxqisRSO8DmCQ5x/3sezI5op1vwOxAcb1osAnuVsVN9bbvpw70HJvhKruFJTuA==}
+ '@cloudflare/workerd-linux-arm64@1.20251217.0':
+ resolution: {integrity: sha512-ixHnHKsiz1Xko+eDgCJOZ7EEUZKtmnYq3AjW3nkVcLFypSLks4C29E45zVewdaN4wq8sCLeyQCl6r1kS17+DQQ==}
engines: {node: '>=16'}
cpu: [arm64]
os: [linux]
- '@cloudflare/workerd-windows-64@1.20251210.0':
- resolution: {integrity: sha512-Uaz6/9XE+D6E7pCY4OvkCuJHu7HcSDzeGcCGY1HLhojXhHd7yL52c3yfiyJdS8hPatiAa0nn5qSI/42+aTdDSw==}
+ '@cloudflare/workerd-windows-64@1.20251217.0':
+ resolution: {integrity: sha512-rP6USX+7ctynz3AtmKi+EvlLP3Xdr1ETrSdcnv693/I5QdUwBxq4yE1Lj6CV7GJizX6opXKYg8QMq0Q4eB9zRQ==}
engines: {node: '>=16'}
cpu: [x64]
os: [win32]
@@ -427,8 +427,8 @@ packages:
glob-to-regexp@0.4.1:
resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==}
- hono@4.10.8:
- resolution: {integrity: sha512-DDT0A0r6wzhe8zCGoYOmMeuGu3dyTAE40HHjwUsWFTEy5WxK1x2WDSsBPlEXgPbRIFY6miDualuUDbasPogIww==}
+ hono@4.11.1:
+ resolution: {integrity: sha512-KsFcH0xxHes0J4zaQgWbYwmz3UPOOskdqZmItstUG93+Wk1ePBLkLGwbP9zlmh1BFUiL8Qp+Xfu9P7feJWpGNg==}
engines: {node: '>=16.9.0'}
is-arrayish@0.3.4:
@@ -447,8 +447,8 @@ packages:
engines: {node: '>=10.0.0'}
hasBin: true
- miniflare@4.20251210.0:
- resolution: {integrity: sha512-k6kIoXwGVqlPZb0hcn+X7BmnK+8BjIIkusQPY22kCo2RaQJ/LzAjtxHQdGXerlHSnJyQivDQsL6BJHMpQfUFyw==}
+ miniflare@4.20251217.0:
+ resolution: {integrity: sha512-8xsTQbPS6YV+ABZl9qiJIbsum6hbpbhqiyKpOVdzZrhK+1N8EFpT8R6aBZff7kezGmxYZSntjgjqTwJmj3JLgA==}
engines: {node: '>=18.0.0'}
hasBin: true
@@ -491,17 +491,17 @@ packages:
unenv@2.0.0-rc.24:
resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==}
- workerd@1.20251210.0:
- resolution: {integrity: sha512-9MUUneP1BnRE9XAYi94FXxHmiLGbO75EHQZsgWqSiOXjoXSqJCw8aQbIEPxCy19TclEl/kHUFYce8ST2W+Qpjw==}
+ workerd@1.20251217.0:
+ resolution: {integrity: sha512-s3mHDSWwHTduyY8kpHOsl27ZJ4ziDBJlc18PfBvNMqNnhO7yBeemlxH7bo7yQyU1foJrIZ6IENHDDg0Z9N8zQA==}
engines: {node: '>=16'}
hasBin: true
- wrangler@4.54.0:
- resolution: {integrity: sha512-bANFsjDwJLbprYoBK+hUDZsVbUv2SqJd8QvArLIcZk+fPq4h/Ohtj5vkKXD3k0s2bD1DXLk08D+hYmeNH+xC6A==}
+ wrangler@4.56.0:
+ resolution: {integrity: sha512-Nqi8duQeRbA+31QrD6QlWHW3IZVnuuRxMy7DEg46deUzywivmaRV/euBN5KKXDPtA24VyhYsK7I0tkb7P5DM2w==}
engines: {node: '>=20.0.0'}
hasBin: true
peerDependencies:
- '@cloudflare/workers-types': ^4.20251210.0
+ '@cloudflare/workers-types': ^4.20251217.0
peerDependenciesMeta:
'@cloudflare/workers-types':
optional: true
@@ -550,25 +550,25 @@ snapshots:
dependencies:
mime: 3.0.0
- '@cloudflare/unenv-preset@2.7.13(unenv@2.0.0-rc.24)(workerd@1.20251210.0)':
+ '@cloudflare/unenv-preset@2.7.13(unenv@2.0.0-rc.24)(workerd@1.20251217.0)':
dependencies:
unenv: 2.0.0-rc.24
optionalDependencies:
- workerd: 1.20251210.0
+ workerd: 1.20251217.0
- '@cloudflare/workerd-darwin-64@1.20251210.0':
+ '@cloudflare/workerd-darwin-64@1.20251217.0':
optional: true
- '@cloudflare/workerd-darwin-arm64@1.20251210.0':
+ '@cloudflare/workerd-darwin-arm64@1.20251217.0':
optional: true
- '@cloudflare/workerd-linux-64@1.20251210.0':
+ '@cloudflare/workerd-linux-64@1.20251217.0':
optional: true
- '@cloudflare/workerd-linux-arm64@1.20251210.0':
+ '@cloudflare/workerd-linux-arm64@1.20251217.0':
optional: true
- '@cloudflare/workerd-windows-64@1.20251210.0':
+ '@cloudflare/workerd-windows-64@1.20251217.0':
optional: true
'@cspotcode/source-map-support@0.8.1':
@@ -832,7 +832,7 @@ snapshots:
glob-to-regexp@0.4.1: {}
- hono@4.10.8: {}
+ hono@4.11.1: {}
is-arrayish@0.3.4: {}
@@ -844,7 +844,7 @@ snapshots:
mime@3.0.0: {}
- miniflare@4.20251210.0:
+ miniflare@4.20251217.0:
dependencies:
'@cspotcode/source-map-support': 0.8.1
acorn: 8.14.0
@@ -854,7 +854,7 @@ snapshots:
sharp: 0.33.5
stoppable: 1.1.0
undici: 7.14.0
- workerd: 1.20251210.0
+ workerd: 1.20251217.0
ws: 8.18.0
youch: 4.1.0-beta.10
zod: 3.22.3
@@ -915,24 +915,24 @@ snapshots:
dependencies:
pathe: 2.0.3
- workerd@1.20251210.0:
+ workerd@1.20251217.0:
optionalDependencies:
- '@cloudflare/workerd-darwin-64': 1.20251210.0
- '@cloudflare/workerd-darwin-arm64': 1.20251210.0
- '@cloudflare/workerd-linux-64': 1.20251210.0
- '@cloudflare/workerd-linux-arm64': 1.20251210.0
- '@cloudflare/workerd-windows-64': 1.20251210.0
+ '@cloudflare/workerd-darwin-64': 1.20251217.0
+ '@cloudflare/workerd-darwin-arm64': 1.20251217.0
+ '@cloudflare/workerd-linux-64': 1.20251217.0
+ '@cloudflare/workerd-linux-arm64': 1.20251217.0
+ '@cloudflare/workerd-windows-64': 1.20251217.0
- wrangler@4.54.0:
+ wrangler@4.56.0:
dependencies:
'@cloudflare/kv-asset-handler': 0.4.1
- '@cloudflare/unenv-preset': 2.7.13(unenv@2.0.0-rc.24)(workerd@1.20251210.0)
+ '@cloudflare/unenv-preset': 2.7.13(unenv@2.0.0-rc.24)(workerd@1.20251217.0)
blake3-wasm: 2.1.5
esbuild: 0.27.0
- miniflare: 4.20251210.0
+ miniflare: 4.20251217.0
path-to-regexp: 6.3.0
unenv: 2.0.0-rc.24
- workerd: 1.20251210.0
+ workerd: 1.20251217.0
optionalDependencies:
fsevents: 2.3.3
transitivePeerDependencies:
diff --git a/public/css/styles.css b/public/css/styles.css
deleted file mode 100644
index ced5226..0000000
--- a/public/css/styles.css
+++ /dev/null
@@ -1,981 +0,0 @@
-/* ===== CSS Variables (shadcn/ui inspired) ===== */
-:root {
- --background: 0 0% 100%;
- --foreground: 222.2 84% 4.9%;
- --card: 0 0% 100%;
- --card-foreground: 222.2 84% 4.9%;
- --popover: 0 0% 100%;
- --popover-foreground: 222.2 84% 4.9%;
- --primary: 330 81% 60%;
- --primary-foreground: 0 0% 100%;
- --secondary: 210 40% 96.1%;
- --secondary-foreground: 222.2 47.4% 11.2%;
- --muted: 210 40% 96.1%;
- --muted-foreground: 215.4 16.3% 46.9%;
- --accent: 330 45% 96%;
- --accent-foreground: 330 81% 30%;
- --destructive: 0 84.2% 60.2%;
- --destructive-foreground: 0 0% 100%;
- --border: 214.3 31.8% 91.4%;
- --input: 214.3 31.8% 91.4%;
- --ring: 330 81% 60%;
- --radius: 0.5rem;
-}
-
-.dark {
- --background: 222.2 84% 4.9%;
- --foreground: 210 40% 98%;
- --card: 222.2 84% 4.9%;
- --card-foreground: 210 40% 98%;
- --popover: 222.2 84% 4.9%;
- --popover-foreground: 210 40% 98%;
- --primary: 330 81% 68%;
- --primary-foreground: 330 20% 10%;
- --secondary: 217.2 32.6% 17.5%;
- --secondary-foreground: 210 40% 98%;
- --muted: 217.2 32.6% 17.5%;
- --muted-foreground: 215 20.2% 65.1%;
- --accent: 330 25% 15%;
- --accent-foreground: 330 81% 68%;
- --destructive: 0 62.8% 50%;
- --destructive-foreground: 210 40% 98%;
- --border: 217.2 32.6% 17.5%;
- --input: 217.2 32.6% 17.5%;
- --ring: 330 81% 68%;
-}
-
-/* ===== Base Styles ===== */
-* {
- margin: 0;
- padding: 0;
- box-sizing: border-box;
-}
-
-body {
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
- background-color: hsl(var(--background));
- color: hsl(var(--foreground));
- line-height: 1.5;
- -webkit-font-smoothing: antialiased;
-}
-
-/* ===== Layout ===== */
-.app-container {
- display: flex;
- min-height: 100vh;
-}
-
-/* Sidebar */
-.sidebar {
- width: 280px;
- border-right: 1px solid hsl(var(--border));
- background-color: hsl(var(--card));
- display: flex;
- flex-direction: column;
- position: fixed;
- height: 100vh;
- overflow-y: auto;
-}
-
-.sidebar-header {
- padding: 1.5rem 1.5rem 1rem;
- border-bottom: 1px solid hsl(var(--border));
-}
-
-.sidebar-brand {
- display: flex;
- align-items: center;
- gap: 0.75rem;
- font-size: 1.25rem;
- font-weight: 600;
-}
-
-.sidebar-nav {
- flex: 1;
- padding: 1rem 0.75rem;
-}
-
-.nav-section {
- margin-bottom: 1.5rem;
-}
-
-.nav-section-title {
- padding: 0.5rem 0.75rem;
- font-size: 0.75rem;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.05em;
- color: hsl(var(--muted-foreground));
-}
-
-.nav-item {
- display: flex;
- align-items: center;
- gap: 0.75rem;
- padding: 0.625rem 0.75rem;
- margin: 0.125rem 0;
- border-radius: var(--radius);
- color: hsl(var(--foreground));
- text-decoration: none;
- cursor: pointer;
- transition: all 0.15s;
- font-size: 0.875rem;
- font-weight: 500;
- border: none;
- background: none;
- width: 100%;
- text-align: left;
-}
-
-.nav-item:hover {
- background-color: hsl(var(--accent));
- color: hsl(var(--accent-foreground));
-}
-
-.nav-item.active {
- background-color: hsl(var(--primary));
- color: hsl(var(--primary-foreground));
-}
-
-.nav-item-icon {
- font-size: 1.25rem;
- width: 1.25rem;
- height: 1.25rem;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.sidebar-footer {
- padding: 1rem 1.5rem;
- border-top: 1px solid hsl(var(--border));
-}
-
-/* Main Content */
-.main-content {
- flex: 1;
- margin-left: 280px;
- min-height: 100vh;
- display: flex;
- flex-direction: column;
-}
-
-.main-header {
- position: sticky;
- top: 0;
- z-index: 10;
- background-color: hsl(var(--background) / 0.95);
- backdrop-filter: blur(8px);
- border-bottom: 1px solid hsl(var(--border));
- padding: 1rem 2rem;
-}
-
-.header-content {
- display: flex;
- align-items: center;
- justify-content: space-between;
- max-width: 1400px;
-}
-
-.header-title {
- font-size: 1.5rem;
- font-weight: 600;
-}
-
-.header-actions {
- display: flex;
- align-items: center;
- gap: 0.75rem;
-}
-
-.content-wrapper {
- flex: 1;
- padding: 2rem;
- max-width: 1400px;
-}
-
-/* ===== Components ===== */
-/* Card */
-.card {
- background-color: hsl(var(--card));
- border: 1px solid hsl(var(--border));
- border-radius: calc(var(--radius) + 2px);
- box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
- display: flex;
- flex-direction: column;
- flex: 1;
- min-height: 0;
-}
-
-.card-header {
- padding: 1.5rem;
- border-bottom: 1px solid hsl(var(--border));
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 1rem;
-}
-
-.card-header > .flex {
- margin-left: auto;
-}
-
-.card-title {
- font-size: 1.25rem;
- font-weight: 600;
- line-height: 1;
-}
-
-.card-description {
- font-size: 0.875rem;
- color: hsl(var(--muted-foreground));
- margin-top: 0.375rem;
-}
-
-.card-content {
- padding: 1.5rem;
- margin-top: 0;
-}
-
-.card-footer {
- padding: 1.5rem;
- border-top: 1px solid hsl(var(--border));
- display: flex;
- align-items: center;
- justify-content: space-between;
-}
-
-/* Button */
-.btn {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 0.5rem;
- border-radius: var(--radius);
- font-size: 0.875rem;
- font-weight: 500;
- padding: 0.625rem 1rem;
- border: none;
- cursor: pointer;
- transition: all 0.15s;
- text-decoration: none;
- white-space: nowrap;
-}
-
-.btn:disabled {
- pointer-events: none;
- opacity: 0.5;
-}
-
-.btn-primary {
- background-color: hsl(var(--primary));
- color: hsl(var(--primary-foreground));
-}
-
-.btn-primary:hover {
- background-color: hsl(var(--primary) / 0.9);
-}
-
-.btn-secondary {
- background-color: hsl(var(--secondary));
- color: hsl(var(--secondary-foreground));
-}
-
-.btn-secondary:hover {
- background-color: hsl(var(--secondary) / 0.8);
-}
-
-.btn-outline {
- background-color: transparent;
- border: 1px solid hsl(var(--input));
- color: hsl(var(--foreground));
-}
-
-.btn-outline:hover {
- background-color: hsl(var(--accent));
- color: hsl(var(--accent-foreground));
-}
-
-.btn-destructive {
- background-color: hsl(var(--destructive));
- color: hsl(var(--destructive-foreground));
-}
-
-.btn-destructive:hover {
- background-color: hsl(var(--destructive) / 0.9);
-}
-
-.btn-ghost {
- background-color: transparent;
- color: hsl(var(--foreground));
-}
-
-.btn-ghost:hover {
- background-color: hsl(var(--accent));
- color: hsl(var(--accent-foreground));
-}
-
-.btn-sm {
- padding: 0.375rem 0.75rem;
- font-size: 0.8125rem;
-}
-
-.btn-lg {
- padding: 0.75rem 1.5rem;
- font-size: 1rem;
-}
-
-/* Input */
-.input {
- display: flex;
- width: 100%;
- border-radius: var(--radius);
- border: 1px solid hsl(var(--input));
- background-color: hsl(var(--background));
- padding: 0.625rem 0.75rem;
- font-size: 0.875rem;
- transition: all 0.15s;
- color: hsl(var(--foreground));
-}
-
-select.input[multiple] {
- display: block;
- padding: 0.5rem;
-}
-
-select.input[multiple] option {
- padding: 0.5rem;
- border-radius: 4px;
- margin-bottom: 2px;
-}
-
-select.input[multiple] option:checked {
- background: hsl(var(--primary));
- color: hsl(var(--primary-foreground));
-}
-
-/* Multi-Select Component */
-.multi-select-wrapper {
- position: relative;
- width: 100%;
-}
-
-.multi-select-trigger {
- display: flex;
- align-items: center;
- justify-content: space-between;
- width: 100%;
- padding: 0.625rem 0.75rem;
- border: 1px solid hsl(var(--input));
- border-radius: var(--radius);
- background-color: hsl(var(--background));
- cursor: pointer;
- transition: all 0.15s;
-}
-
-.multi-select-trigger:hover {
- border-color: hsl(var(--ring));
-}
-
-.multi-select-value {
- flex: 1;
- font-size: 0.875rem;
- color: hsl(var(--foreground));
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.multi-select-value.placeholder {
- color: hsl(var(--muted-foreground));
-}
-
-.multi-select-arrow {
- font-size: 0.75rem;
- color: hsl(var(--muted-foreground));
- transition: transform 0.2s;
-}
-
-.multi-select-trigger.open .multi-select-arrow {
- transform: rotate(180deg);
-}
-
-.multi-select-dropdown {
- position: absolute;
- top: calc(100% + 0.25rem);
- left: 0;
- right: 0;
- background: hsl(var(--background));
- border: 1px solid hsl(var(--input));
- border-radius: var(--radius);
- box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
- z-index: 50;
- max-height: 300px;
- overflow: hidden;
- display: flex;
- flex-direction: column;
-}
-
-.multi-select-search {
- padding: 0.5rem;
- border-bottom: 1px solid hsl(var(--border));
-}
-
-.multi-select-search .input {
- padding: 0.5rem;
- font-size: 0.875rem;
-}
-
-.multi-select-options {
- overflow-y: auto;
- max-height: 250px;
-}
-
-.multi-select-option {
- display: flex;
- align-items: center;
- padding: 0.625rem 0.75rem;
- cursor: pointer;
- transition: background-color 0.15s;
- gap: 0.5rem;
-}
-
-.multi-select-option:hover {
- background-color: hsl(var(--accent));
-}
-
-.multi-select-option input[type="checkbox"] {
- width: 1rem;
- height: 1rem;
- cursor: pointer;
-}
-
-.multi-select-option label {
- flex: 1;
- font-size: 0.875rem;
- cursor: pointer;
- margin: 0;
-}
-
-.multi-select-loading,
-.multi-select-empty {
- padding: 1rem;
- text-align: center;
- color: hsl(var(--muted-foreground));
- font-size: 0.875rem;
-}
-
-.input:focus {
- outline: none;
- border-color: hsl(var(--ring));
- box-shadow: 0 0 0 2px hsl(var(--ring) / 0.2);
-}
-
-.input:disabled {
- cursor: not-allowed;
- opacity: 0.5;
-}
-
-/* Label */
-.label {
- display: block;
- font-size: 0.875rem;
- font-weight: 500;
- margin-bottom: 0.5rem;
- color: hsl(var(--foreground));
-}
-
-/* Badge */
-.badge {
- display: inline-flex;
- align-items: center;
- border-radius: calc(var(--radius) - 2px);
- padding: 0.25rem 0.625rem;
- font-size: 0.75rem;
- font-weight: 600;
- line-height: 1;
- transition: all 0.15s;
-}
-
-.badge-default {
- background-color: hsl(var(--primary));
- color: hsl(var(--primary-foreground));
-}
-
-.badge-secondary {
- background-color: hsl(var(--secondary));
- color: hsl(var(--secondary-foreground));
-}
-
-.badge-success {
- background-color: hsl(142.1 76.2% 36.3%);
- color: hsl(0 0% 100%);
-}
-
-.badge-warning {
- background-color: hsl(47.9 95.8% 53.1%);
- color: hsl(26 83.3% 14.1%);
-}
-
-.badge-destructive {
- background-color: hsl(var(--destructive));
- color: hsl(var(--destructive-foreground));
-}
-
-.badge-outline {
- background-color: transparent;
- border: 1px solid hsl(var(--border));
- color: hsl(var(--foreground));
-}
-
-/* Table */
-.table-container {
- overflow-x: auto;
- border-radius: var(--radius);
- border: 1px solid hsl(var(--border));
-}
-
-.table {
- width: 100%;
- border-collapse: collapse;
- font-size: 0.875rem;
-}
-
-.table thead {
- background-color: hsl(var(--muted));
-}
-
-.table th {
- padding: 0.75rem 1rem;
- text-align: left;
- font-weight: 600;
- color: hsl(var(--muted-foreground));
- border-bottom: 1px solid hsl(var(--border));
-}
-
-.table td {
- padding: 0.75rem 1rem;
- border-bottom: 1px solid hsl(var(--border));
-}
-
-.table tbody tr:hover {
- background-color: hsl(var(--muted) / 0.5);
-}
-
-.table tbody tr:last-child td {
- border-bottom: none;
-}
-
-/* Alert */
-.alert {
- padding: 1rem;
- border-radius: var(--radius);
- border: 1px solid hsl(var(--border));
- margin-bottom: 1rem;
-}
-
-.alert-info {
- background-color: hsl(330 100% 97%);
- border-color: hsl(330 81% 85%);
- color: hsl(330 81% 30%);
-}
-
-.dark .alert-info {
- background-color: hsl(330 40% 15%);
- border-color: hsl(330 40% 25%);
- color: hsl(330 81% 85%);
-}
-
-.alert-success {
- background-color: hsl(142.1 76.2% 36.3% / 0.1);
- border-color: hsl(142.1 76.2% 36.3% / 0.3);
-}
-
-.alert-warning {
- background-color: hsl(47.9 95.8% 53.1% / 0.1);
- border-color: hsl(47.9 95.8% 53.1% / 0.3);
-}
-
-.alert-error {
- background-color: hsl(var(--destructive) / 0.1);
- border-color: hsl(var(--destructive) / 0.3);
-}
-
-/* Modal */
-.modal-overlay {
- position: fixed;
- inset: 0;
- background-color: rgba(0, 0, 0, 0.5);
- backdrop-filter: blur(4px);
- z-index: 50;
- display: none;
- align-items: center;
- justify-content: center;
- animation: fadeIn 0.2s ease;
-}
-
-.modal-overlay.active {
- display: flex;
-}
-
-.modal-content {
- background-color: hsl(var(--card));
- border: 1px solid hsl(var(--border));
- border-radius: calc(var(--radius) + 4px);
- box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
- max-width: 32rem;
- width: 90%;
- max-height: 90vh;
- overflow-y: auto;
- animation: slideUp 0.2s ease;
-}
-
-.modal-header {
- padding: 1.5rem;
- border-bottom: 1px solid hsl(var(--border));
-}
-
-.modal-title {
- font-size: 1.25rem;
- font-weight: 600;
-}
-
-.modal-body {
- padding: 1.5rem;
-}
-
-.modal-footer {
- padding: 1.5rem;
- border-top: 1px solid hsl(var(--border));
- display: flex;
- justify-content: flex-end;
- gap: 0.75rem;
-}
-
-/* Form Group */
-.form-group {
- margin-bottom: 1.25rem;
-}
-
-.form-group:last-child {
- margin-bottom: 0;
-}
-
-/* Grid */
-.grid {
- display: grid;
- gap: 1.5rem;
-}
-
-.grid-cols-2 {
- grid-template-columns: repeat(2, 1fr);
-}
-
-.grid-cols-3 {
- grid-template-columns: repeat(3, 1fr);
-}
-
-.grid-cols-4 {
- grid-template-columns: repeat(4, 1fr);
-}
-
-/* Stats Card */
-.stats-card {
- background-color: hsl(var(--card));
- border: 1px solid hsl(var(--border));
- border-radius: calc(var(--radius) + 2px);
- padding: 1.5rem;
- display: flex;
- flex-direction: column;
- gap: 0.75rem;
-}
-
-.stats-card-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
-}
-
-.stats-card-title {
- font-size: 0.875rem;
- font-weight: 500;
- color: hsl(var(--muted-foreground));
-}
-
-.stats-card-value {
- font-size: 2rem;
- font-weight: 700;
-}
-
-.stats-card-trend {
- font-size: 0.875rem;
- color: hsl(var(--muted-foreground));
-}
-
-/* Animations */
-@keyframes fadeIn {
- from { opacity: 0; }
- to { opacity: 1; }
-}
-
-@keyframes slideUp {
- from {
- opacity: 0;
- transform: translateY(20px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
-}
-
-@keyframes slideIn {
- from { transform: translateX(100%); opacity: 0; }
- to { transform: translateX(0); opacity: 1; }
-}
-
-@keyframes fadeOut {
- from { opacity: 1; }
- to { opacity: 0; }
-}
-
-/* Utilities */
-.hidden {
- display: none !important;
-}
-
-.flex {
- display: flex;
-}
-
-.items-center {
- align-items: center;
-}
-
-.justify-between {
- justify-content: space-between;
-}
-
-.gap-2 {
- gap: 0.5rem;
-}
-
-.gap-4 {
- gap: 1rem;
-}
-
-.mt-1 {
- margin-top: 0.25rem;
-}
-
-.mt-2 {
- margin-top: 0.5rem;
-}
-
-.mb-3 {
- margin-bottom: 0.75rem;
-}
-
-.mb-4 {
- margin-bottom: 1rem;
-}
-
-.mb-6 {
- margin-bottom: 1.5rem;
-}
-
-.text-sm {
- font-size: 0.875rem;
-}
-
-.text-center {
- text-align: center;
-}
-
-.text-muted {
- color: hsl(var(--muted-foreground));
-}
-
-.font-medium {
- font-weight: 500;
-}
-
-.font-semibold {
- font-weight: 600;
-}
-
-.w-full {
- width: 100%;
-}
-
-code {
- background-color: hsl(var(--muted));
- padding: 0.125rem 0.25rem;
- border-radius: 0.25rem;
- font-size: 0.875em;
- font-family: 'Courier New', monospace;
-}
-
-/* Tab Content */
-.tab-content {
- display: none;
-}
-
-.tab-content.active {
- display: flex;
- flex-direction: column;
- flex: 1;
-}
-
-/* Responsive */
-@media (max-width: 1024px) {
- .sidebar {
- transform: translateX(-100%);
- transition: transform 0.3s;
- }
-
- .sidebar.mobile-open {
- transform: translateX(0);
- }
-
- .main-content {
- margin-left: 0;
- }
-
- .grid-cols-2,
- .grid-cols-3,
- .grid-cols-4 {
- grid-template-columns: 1fr;
- }
-}
-
-/* ===== Loading Overlay ===== */
-#loadingOverlay {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(0, 0, 0, 0.5);
- backdrop-filter: blur(4px);
- z-index: 9999;
- display: flex;
- flex-direction: column;
- justify-content: center;
- align-items: center;
- opacity: 0;
- transition: opacity 0.2s;
- pointer-events: none;
-}
-
-#loadingOverlay.active {
- opacity: 1;
- pointer-events: auto;
-}
-
-.loading-spinner {
- width: 48px;
- height: 48px;
- border: 3px solid rgba(255, 255, 255, 0.2);
- border-top-color: hsl(var(--primary));
- border-radius: 50%;
- animation: spin 0.8s linear infinite;
-}
-
-.loading-text {
- margin-top: 1rem;
- color: white;
- font-size: 0.875rem;
- font-weight: 500;
-}
-
-@keyframes spin {
- 0% { transform: rotate(0deg); }
- 100% { transform: rotate(360deg); }
-}
-
-/* ===== Item List Styles ===== */
-#tokenListView,
-#channelListView,
-#pricingContent {
- display: flex;
- flex-direction: column;
- flex: 1;
-}
-
-.item-list {
- flex: 1;
- overflow-y: auto;
- padding: 1.5rem;
- padding-right: 1.5rem;
- min-height: 0;
-}
-
-.item-list::-webkit-scrollbar {
- width: 6px;
-}
-
-.item-list::-webkit-scrollbar-track {
- background: hsl(var(--muted));
- border-radius: 3px;
-}
-
-.item-list::-webkit-scrollbar-thumb {
- background: hsl(var(--border));
- border-radius: 3px;
-}
-
-.item-list::-webkit-scrollbar-thumb:hover {
- background: hsl(var(--muted-foreground));
-}
-
-.item {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 18px 20px;
- border: 1px solid hsl(var(--border));
- border-radius: var(--radius);
- margin-bottom: 12px;
- background: hsl(var(--card));
- transition: all 0.2s ease;
-}
-
-.item:hover {
- border-color: hsl(var(--primary));
- box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
- transform: translateX(4px);
-}
-
-.item-info {
- flex: 1;
- min-width: 0;
-}
-
-.item-key {
- font-weight: 600;
- color: hsl(var(--foreground));
- margin-bottom: 6px;
- font-size: 15px;
-}
-
-.item-details {
- font-size: 13px;
- color: hsl(var(--muted-foreground));
- line-height: 1.5;
-}
-
-.actions {
- display: flex;
- gap: 8px;
- flex-shrink: 0;
-}
-
-.btn-danger {
- background-color: hsl(var(--destructive));
- color: hsl(var(--destructive-foreground));
-}
-
-.btn-danger:hover {
- opacity: 0.9;
-}
diff --git a/public/index.html b/public/index.html
deleted file mode 100644
index 813b4c5..0000000
--- a/public/index.html
+++ /dev/null
@@ -1,590 +0,0 @@
-
-
-
-
-
- AWSL One API - 管理控制台
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
💡 提示
-
要使用管理功能,请先进行身份验证
-
-
-
-
-
-
-
-
-
- AWSL One API 是一个强大的 API 代理和管理平台,为您提供统一的 AI 模型接口管理服务。
-
-
-
核心特性
-
-
-
-
🌐 多提供商支持
-
支持 OpenAI、Azure OpenAI 和 Claude API,轻松切换不同的 AI 服务。
-
-
-
-
-
⚖️ 负载均衡
-
自动在多个频道间分配请求,确保服务稳定性和高可用性。
-
-
-
-
-
📊 用量追踪
-
实时监控 token 使用情况,精确计算成本,防止超出配额。
-
-
-
-
-
🔐 安全管理
-
基于令牌的访问控制,为不同用户分配独立的 API 密钥。
-
-
-
-
-
-
-
-
-
-
- - 管理员登录:使用管理员令牌登录系统
- - 配置频道:添加 OpenAI、Azure 或 Claude API 频道
- - 创建令牌:为您的应用创建 API 令牌
- - 开始使用:使用生成的令牌调用 API
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
💡 定价说明
-
- 基础配额单位:1百万token = $1.00 | 定价配置为不同模型相对于基础单位的倍率 | 例如:gpt-4的input倍率为30,表示1百万token消耗30美元配额
-
-
-
-
-
-
-
-
-
- | 模型名称 |
- 输入倍率 |
- 输出倍率 |
- 操作 |
-
-
-
-
- | 加载中... |
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
⚠️ 警告:
-
初始化数据库将创建必要的表结构。如果表已存在,操作将被忽略。
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/public/js/api-test.js b/public/js/api-test.js
deleted file mode 100644
index 2e27ef8..0000000
--- a/public/js/api-test.js
+++ /dev/null
@@ -1,123 +0,0 @@
-// ===== API Test Module =====
-import { showNotification } from './utils.js';
-
-// API Test Template
-export function updateRequestTemplate() {
- const endpoint = document.getElementById('testEndpoint').value;
- const requestBody = document.getElementById('testRequestBody');
-
- const templates = {
- '/v1/chat/completions': {
- model: "gpt-3.5-turbo",
- messages: [
- { role: "user", content: "你好,请介绍一下你自己。" }
- ],
- temperature: 0.7,
- max_tokens: 1024
- },
- '/v1/messages': {
- model: "claude-haiku-4-5-20251001",
- max_tokens: 1024,
- messages: [
- { role: "user", content: "你好,请介绍一下你自己。" }
- ]
- },
- '/v1/completions': {
- model: "gpt-3.5-turbo-instruct",
- prompt: "你好,请介绍一下你自己。",
- max_tokens: 1024,
- temperature: 0.7
- }
- };
-
- requestBody.value = JSON.stringify(templates[endpoint], null, 2);
-}
-
-export async function testAPI(event) {
- event.preventDefault();
-
- const endpoint = document.getElementById('testEndpoint').value;
- const token = document.getElementById('testApiToken').value;
- const requestBodyText = document.getElementById('testRequestBody').value;
-
- const resultDiv = document.getElementById('testResult');
- const resultContent = document.getElementById('testResultContent');
- const statusBadge = document.getElementById('testStatusBadge');
- const timeBadge = document.getElementById('testTimeBadge');
- const submitBtn = document.getElementById('testSubmitBtn');
-
- // Validate JSON
- let requestBody;
- try {
- requestBody = JSON.parse(requestBodyText);
- } catch (error) {
- showNotification('JSON 格式错误:' + error.message, 'error');
- return;
- }
-
- resultDiv.classList.remove('hidden');
- resultContent.textContent = '发送请求中...';
- statusBadge.textContent = '请求中';
- statusBadge.className = 'badge badge-warning';
- submitBtn.disabled = true;
-
- const startTime = Date.now();
-
- try {
- const response = await fetch(endpoint, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'Authorization': `Bearer ${token}`
- },
- body: JSON.stringify(requestBody)
- });
-
- const endTime = Date.now();
- const duration = endTime - startTime;
- timeBadge.textContent = `${duration}ms`;
-
- if (response.ok) {
- statusBadge.textContent = '成功';
- statusBadge.className = 'badge badge-success';
- } else {
- statusBadge.textContent = `失败 ${response.status}`;
- statusBadge.className = 'badge badge-destructive';
- }
-
- // Handle streaming vs non-streaming
- const isStreaming = requestBody.stream === true;
-
- if (isStreaming) {
- resultContent.textContent = '';
- const reader = response.body.getReader();
- const decoder = new TextDecoder();
-
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
-
- const chunk = decoder.decode(value);
- resultContent.textContent += chunk;
- }
- } else {
- const data = await response.json();
- resultContent.textContent = JSON.stringify(data, null, 2);
- }
-
- if (!response.ok) {
- showNotification('API 请求失败', 'error');
- }
- } catch (error) {
- const endTime = Date.now();
- const duration = endTime - startTime;
- timeBadge.textContent = `${duration}ms`;
-
- statusBadge.textContent = '错误';
- statusBadge.className = 'badge badge-destructive';
- resultContent.textContent = '错误:' + error.message;
- showNotification('请求失败:' + error.message, 'error');
- } finally {
- submitBtn.disabled = false;
- }
-}
diff --git a/public/js/auth.js b/public/js/auth.js
deleted file mode 100644
index 8d57ccd..0000000
--- a/public/js/auth.js
+++ /dev/null
@@ -1,80 +0,0 @@
-// ===== Authentication Module =====
-import { toggleModal, showNotification, showLoading, hideLoading } from './utils.js';
-
-export function toggleAuthModal() {
- const adminToken = localStorage.getItem('adminToken');
-
- if (adminToken) {
- // Logout
- if (confirm('确定要退出登录吗?')) {
- localStorage.removeItem('adminToken');
- onLogout();
- }
- } else {
- toggleModal('authModal');
- }
-}
-
-export async function authenticate(event) {
- event.preventDefault();
- const token = document.getElementById('adminToken').value;
-
- showLoading('验证令牌...');
- try {
- const response = await fetch('/api/admin/channel', {
- headers: { 'x-admin-token': token }
- });
-
- if (response.ok) {
- localStorage.setItem('adminToken', token);
- toggleModal('authModal');
- onAuthenticated();
- showNotification('登录成功', 'success');
- } else {
- showAuthError('管理员令牌无效');
- }
- } catch (error) {
- showAuthError('认证失败:' + error.message);
- } finally {
- hideLoading();
- }
-}
-
-function showAuthError(message) {
- document.getElementById('authError').classList.remove('hidden');
- document.getElementById('authErrorMessage').textContent = message;
-}
-
-function onAuthenticated() {
- document.querySelectorAll('.admin-only').forEach(el => {
- el.classList.remove('hidden');
- });
- document.getElementById('loginPrompt').classList.add('hidden');
- const authButton = document.getElementById('authButton');
- authButton.textContent = '🚪 退出登录';
- authButton.style.color = 'hsl(var(--destructive))';
- authButton.style.borderColor = 'hsl(var(--destructive))';
-}
-
-function onLogout() {
- document.querySelectorAll('.admin-only').forEach(el => {
- el.classList.add('hidden');
- });
- document.getElementById('loginPrompt').classList.remove('hidden');
- const authButton = document.getElementById('authButton');
- authButton.textContent = '🔑 管理员登录';
- authButton.style.color = '';
- authButton.style.borderColor = '';
-
- if (window.switchTab) {
- window.switchTab('dashboard');
- }
-}
-
-// Initialize on page load
-export function initAuth() {
- const adminToken = localStorage.getItem('adminToken');
- if (adminToken) {
- onAuthenticated();
- }
-}
diff --git a/public/js/channels.js b/public/js/channels.js
deleted file mode 100644
index 6125887..0000000
--- a/public/js/channels.js
+++ /dev/null
@@ -1,407 +0,0 @@
-// ===== Channel Management Module =====
-import { toggleModal, showNotification, showLoading, hideLoading } from './utils.js';
-
-let channelEditMode = 'form'; // 'form' or 'json'
-let currentEditingKey = null;
-
-// Add model mapper row
-export function addMapperRow() {
- const tbody = document.getElementById('channelMapperTable');
- if (!tbody) return;
-
- // Remove empty message if exists
- if (tbody.querySelector('.text-center')) {
- tbody.innerHTML = '';
- }
-
- const row = document.createElement('tr');
- row.innerHTML = `
-
-
- |
-
-
- |
-
-
- |
- `;
- tbody.appendChild(row);
-}
-
-// Remove model mapper row
-export function removeMapperRow(button) {
- const row = button.closest('tr');
- if (row) {
- row.remove();
- }
-
- // If no rows left, show empty message
- const tbody = document.getElementById('channelMapperTable');
- if (tbody && tbody.children.length === 0) {
- tbody.innerHTML = '| 暂无映射,点击上方按钮添加 |
';
- }
-}
-
-// Display mapper table
-function displayMapperTable(mapper) {
- const tbody = document.getElementById('channelMapperTable');
- if (!tbody) return;
-
- tbody.innerHTML = '';
-
- if (!mapper || Object.keys(mapper).length === 0) {
- tbody.innerHTML = '| 暂无映射,点击上方按钮添加 |
';
- return;
- }
-
- Object.entries(mapper).forEach(([requestModel, deploymentModel]) => {
- const row = document.createElement('tr');
- row.innerHTML = `
-
-
- |
-
-
- |
-
-
- |
- `;
- tbody.appendChild(row);
- });
-}
-
-// Collect mapper table data
-function collectMapperTableData() {
- const tbody = document.getElementById('channelMapperTable');
- const mapper = {};
-
- if (!tbody) return mapper;
-
- Array.from(tbody.children).forEach((row) => {
- // Skip empty message row
- if (row.querySelector('.text-center')) return;
-
- const inputs = row.querySelectorAll('input');
- if (inputs.length < 2) return;
-
- const requestModel = inputs[0].value.trim();
- const deploymentModel = inputs[1].value.trim();
-
- if (requestModel && deploymentModel) {
- mapper[requestModel] = deploymentModel;
- }
- });
-
- return mapper;
-}
-
-// Load channels from API
-export async function loadChannels() {
- const adminToken = localStorage.getItem('adminToken');
- showLoading('加载频道列表...');
- try {
- const response = await fetch('/api/admin/channel', {
- headers: { 'x-admin-token': adminToken }
- });
-
- if (response.ok) {
- const result = await response.json();
- const channels = result.data || [];
- displayChannels(channels);
- }
- } catch (error) {
- console.error('Failed to load channels:', error);
- showNotification('加载频道失败', 'error');
- } finally {
- hideLoading();
- }
-}
-
-// Display channels in the list
-function displayChannels(channels) {
- const container = document.getElementById('channelsList');
- if (!container) return;
-
- if (!channels || channels.length === 0) {
- container.innerHTML = '📂 暂无频道数据
';
- return;
- }
-
- container.innerHTML = channels.map(item => {
- let parsedValue = {};
- try {
- parsedValue = typeof item.value === 'string' ? JSON.parse(item.value) : item.value;
- } catch {
- parsedValue = { name: 'Invalid JSON', type: 'unknown', endpoint: '' };
- }
-
- const name = parsedValue.name || 'No name';
- const type = parsedValue.type || 'Unknown';
- const endpoint = parsedValue.endpoint || '';
- const endpointDisplay = endpoint.length > 50 ? endpoint.substring(0, 50) + '...' : endpoint;
- const modelCount = Object.keys(parsedValue.deployment_mapper || {}).length;
-
- return `
-
-
-
${name}
-
- ${item.key} | 类型: ${type} | 端点: ${endpointDisplay} | ${modelCount} 个模型
-
-
-
-
-
-
-
- `;
- }).join('');
-}
-
-// Show channel list view
-export function showChannelList() {
- document.getElementById('channelListView').style.display = 'block';
- document.getElementById('channelFormView').style.display = 'none';
- loadChannels();
-}
-
-// Show channel form for adding
-export function showChannelForm() {
- currentEditingKey = null;
- document.getElementById('channelFormTitle').textContent = '添加频道';
- document.getElementById('channelListView').style.display = 'none';
- document.getElementById('channelFormView').style.display = 'block';
- clearChannelForm();
- // Reset to form mode
- channelEditMode = 'form';
- document.getElementById('channelFormMode').style.display = 'block';
- document.getElementById('channelJsonMode').style.display = 'none';
- document.getElementById('channelModeToggle').textContent = '📝 切换到JSON模式';
-
- // Channel key should be editable when creating
- document.getElementById('channelKey').disabled = false;
- document.getElementById('channelKey').placeholder = '例如: azure-openai-1';
-}
-
-// Edit existing channel
-export async function editChannel(key) {
- currentEditingKey = key;
- document.getElementById('channelFormTitle').textContent = '编辑频道';
- document.getElementById('channelListView').style.display = 'none';
- document.getElementById('channelFormView').style.display = 'block';
-
- const adminToken = localStorage.getItem('adminToken');
- showLoading('加载频道数据...');
- try {
- const response = await fetch('/api/admin/channel', {
- headers: { 'x-admin-token': adminToken }
- });
-
- if (response.ok) {
- const result = await response.json();
- const channels = result.data || [];
- const channel = channels.find(c => c.key === key);
-
- if (channel) {
- const config = typeof channel.value === 'string' ? JSON.parse(channel.value) : channel.value;
- fillChannelForm(key, config);
-
- // Set channel key as readonly when editing
- document.getElementById('channelKey').disabled = true;
- }
- }
- } catch (error) {
- showNotification('加载频道数据失败', 'error');
- } finally {
- hideLoading();
- }
-}
-
-// Fill form with channel data
-function fillChannelForm(key, config) {
- document.getElementById('channelKey').value = key;
- // Don't change disabled state here - it's set by editChannel
- document.getElementById('channelName').value = config.name || '';
- document.getElementById('channelType').value = config.type || 'azure-openai';
- document.getElementById('channelEndpoint').value = config.endpoint || '';
- document.getElementById('channelApiKey').value = config.api_key || '';
- document.getElementById('channelApiVersion').value = config.api_version || '';
-
- // Display mapper table
- displayMapperTable(config.deployment_mapper || {});
-
- // Update JSON mode
- document.getElementById('channelValueJson').value = JSON.stringify(config, null, 2);
-}
-
-// Clear form
-function clearChannelForm() {
- document.getElementById('channelKey').value = '';
- document.getElementById('channelKey').disabled = false;
- document.getElementById('channelName').value = '';
- document.getElementById('channelType').value = 'azure-openai';
- document.getElementById('channelEndpoint').value = '';
- document.getElementById('channelApiKey').value = '';
- document.getElementById('channelApiVersion').value = '';
-
- // Clear mapper table
- const tbody = document.getElementById('channelMapperTable');
- if (tbody) {
- tbody.innerHTML = '| 暂无映射,点击上方按钮添加 |
';
- }
-
- document.getElementById('channelValueJson').value = '';
-}
-
-// Toggle between form and JSON mode
-export function toggleChannelEditMode() {
- if (channelEditMode === 'form') {
- // Switch to JSON mode - collect form data and show in JSON
- const formData = collectChannelFormData();
- if (formData) {
- document.getElementById('channelValueJson').value = JSON.stringify(formData, null, 2);
- }
- document.getElementById('channelFormMode').style.display = 'none';
- document.getElementById('channelJsonMode').style.display = 'block';
- document.getElementById('channelModeToggle').textContent = '📋 切换到表单模式';
- channelEditMode = 'json';
- } else {
- // Switch to form mode - parse JSON and fill form
- try {
- const jsonValue = document.getElementById('channelValueJson').value;
- if (jsonValue) {
- const config = JSON.parse(jsonValue);
- document.getElementById('channelName').value = config.name || '';
- document.getElementById('channelType').value = config.type || 'azure-openai';
- document.getElementById('channelEndpoint').value = config.endpoint || '';
- document.getElementById('channelApiKey').value = config.api_key || '';
- document.getElementById('channelApiVersion').value = config.api_version || '';
-
- // Display mapper table
- displayMapperTable(config.deployment_mapper || {});
- }
- } catch (error) {
- showNotification('JSON格式错误', 'error');
- return;
- }
- document.getElementById('channelFormMode').style.display = 'block';
- document.getElementById('channelJsonMode').style.display = 'none';
- document.getElementById('channelModeToggle').textContent = '📝 切换到JSON模式';
- channelEditMode = 'form';
- }
-}
-
-// Collect form data
-function collectChannelFormData() {
- const name = document.getElementById('channelName').value;
- const type = document.getElementById('channelType').value;
- const endpoint = document.getElementById('channelEndpoint').value;
- const apiKey = document.getElementById('channelApiKey').value;
- const apiVersion = document.getElementById('channelApiVersion').value;
-
- if (!name || !endpoint || !apiKey) {
- return null;
- }
-
- // Collect mapper data from table
- const deploymentMapper = collectMapperTableData();
-
- const config = {
- name,
- type,
- endpoint,
- api_key: apiKey,
- deployment_mapper: deploymentMapper
- };
-
- if (apiVersion) {
- config.api_version = apiVersion;
- }
-
- return config;
-}
-
-// Save channel (add or update)
-export async function saveChannel() {
- const key = document.getElementById('channelKey').value || currentEditingKey;
- if (!key) {
- showNotification('请填写频道标识', 'error');
- return;
- }
-
- let config;
- if (channelEditMode === 'form') {
- config = collectChannelFormData();
- if (!config) {
- showNotification('请填写所有必填字段(频道名称、端点、API密钥)', 'error');
- return;
- }
- } else {
- const jsonValue = document.getElementById('channelValueJson').value;
- if (!jsonValue) {
- showNotification('请填写配置JSON', 'error');
- return;
- }
- try {
- config = JSON.parse(jsonValue);
- } catch {
- showNotification('JSON格式错误,请检查配置格式', 'error');
- return;
- }
- }
-
- const adminToken = localStorage.getItem('adminToken');
-
- showLoading('保存频道...');
- try {
- const response = await fetch(`/api/admin/channel/${encodeURIComponent(key)}`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'x-admin-token': adminToken
- },
- body: JSON.stringify(config)
- });
-
- if (response.ok) {
- showNotification(currentEditingKey ? '频道更新成功' : '频道添加成功', 'success');
- clearChannelForm();
- showChannelList();
- } else {
- const error = await response.text();
- showNotification('保存失败:' + error, 'error');
- }
- } catch (error) {
- showNotification('保存失败:' + error.message, 'error');
- } finally {
- hideLoading();
- }
-}
-
-// Delete channel
-export async function deleteChannel(key) {
- if (!confirm(`确定要删除频道 "${key}" 吗?`)) return;
-
- const adminToken = localStorage.getItem('adminToken');
-
- showLoading('删除频道...');
- try {
- const response = await fetch(`/api/admin/channel/${encodeURIComponent(key)}`, {
- method: 'DELETE',
- headers: { 'x-admin-token': adminToken }
- });
-
- if (response.ok) {
- showNotification('频道已删除', 'success');
- loadChannels();
- } else {
- showNotification('删除失败', 'error');
- }
- } catch (error) {
- showNotification('删除失败:' + error.message, 'error');
- } finally {
- hideLoading();
- }
-}
diff --git a/public/js/database.js b/public/js/database.js
deleted file mode 100644
index 9748458..0000000
--- a/public/js/database.js
+++ /dev/null
@@ -1,27 +0,0 @@
-// ===== Database Management Module =====
-import { showNotification, showLoading, hideLoading } from './utils.js';
-
-export async function initDatabase() {
- const adminToken = localStorage.getItem('adminToken');
- showLoading('初始化数据库...');
- try {
- const response = await fetch('/api/admin/db_initialize', {
- method: 'POST',
- headers: { 'x-admin-token': adminToken }
- });
-
- const data = await response.json();
- document.getElementById('dbResult').classList.remove('hidden');
- document.getElementById('dbResultContent').textContent = JSON.stringify(data, null, 2);
-
- if (response.ok) {
- showNotification('数据库初始化成功', 'success');
- } else {
- showNotification('数据库初始化失败', 'error');
- }
- } catch (error) {
- showNotification('初始化失败:' + error.message, 'error');
- } finally {
- hideLoading();
- }
-}
diff --git a/public/js/main.js b/public/js/main.js
deleted file mode 100644
index 9c51150..0000000
--- a/public/js/main.js
+++ /dev/null
@@ -1,65 +0,0 @@
-// ===== Main Entry Point =====
-import { toggleModal, switchTab, toggleTheme, showNotification, copyToClipboard } from './utils.js';
-import { toggleAuthModal, authenticate, initAuth } from './auth.js';
-import { updateRequestTemplate, testAPI } from './api-test.js';
-import { loadChannels, showChannelList, showChannelForm, editChannel, saveChannel, toggleChannelEditMode, deleteChannel, addMapperRow, removeMapperRow } from './channels.js';
-import { loadTokens, showTokenList, showTokenForm, editToken, saveToken, generateTokenKey, toggleTokenEditMode, deleteToken, setQuotaValue, toggleChannelDropdown, toggleChannelSelection, filterChannels, handleChannelCheckboxChange } from './tokens.js';
-import { loadPricing, savePricing, addPricingModel, removePricingRow, togglePricingEditMode } from './pricing.js';
-import { initDatabase } from './database.js';
-
-// Expose functions to global scope for onclick handlers
-window.toggleModal = toggleModal;
-window.switchTab = switchTab;
-window.toggleTheme = toggleTheme;
-window.copyToClipboard = copyToClipboard;
-window.toggleAuthModal = toggleAuthModal;
-window.authenticate = authenticate;
-window.updateRequestTemplate = updateRequestTemplate;
-window.testAPI = testAPI;
-window.loadChannels = loadChannels;
-window.showChannelList = showChannelList;
-window.showChannelForm = showChannelForm;
-window.editChannel = editChannel;
-window.saveChannel = saveChannel;
-window.toggleChannelEditMode = toggleChannelEditMode;
-window.deleteChannel = deleteChannel;
-window.addMapperRow = addMapperRow;
-window.removeMapperRow = removeMapperRow;
-window.loadTokens = loadTokens;
-window.showTokenList = showTokenList;
-window.showTokenForm = showTokenForm;
-window.editToken = editToken;
-window.saveToken = saveToken;
-window.generateTokenKey = generateTokenKey;
-window.setQuotaValue = setQuotaValue;
-window.toggleTokenEditMode = toggleTokenEditMode;
-window.deleteToken = deleteToken;
-window.toggleChannelDropdown = toggleChannelDropdown;
-window.toggleChannelSelection = toggleChannelSelection;
-window.handleChannelCheckboxChange = handleChannelCheckboxChange;
-window.filterChannels = filterChannels;
-window.loadPricing = loadPricing;
-window.savePricing = savePricing;
-window.addPricingModel = addPricingModel;
-window.removePricingRow = removePricingRow;
-window.togglePricingEditMode = togglePricingEditMode;
-window.initDatabase = initDatabase;
-
-// Initialize on DOMContentLoaded
-document.addEventListener('DOMContentLoaded', () => {
- // Initialize theme
- const isDark = localStorage.getItem('theme') === 'dark';
- if (isDark) {
- document.documentElement.classList.add('dark');
- document.getElementById('themeIcon').textContent = '☀️';
- } else {
- document.documentElement.classList.remove('dark');
- document.getElementById('themeIcon').textContent = '🌙';
- }
-
- // Initialize authentication
- initAuth();
-
- // Initialize API test template
- updateRequestTemplate();
-});
diff --git a/public/js/pricing.js b/public/js/pricing.js
deleted file mode 100644
index 0aaf903..0000000
--- a/public/js/pricing.js
+++ /dev/null
@@ -1,203 +0,0 @@
-// ===== Pricing Management Module =====
-import { showNotification, showLoading, hideLoading } from './utils.js';
-
-let pricingEditMode = 'table'; // 'table' or 'json'
-
-export async function loadPricing() {
- const adminToken = localStorage.getItem('adminToken');
- showLoading('加载定价配置...');
- try {
- const response = await fetch('/api/admin/pricing', {
- headers: { 'x-admin-token': adminToken }
- });
-
- if (response.ok) {
- const data = await response.json();
- const pricing = data.data || {};
-
- if (pricingEditMode === 'table') {
- displayPricingTable(pricing);
- const addBtn = document.getElementById('addPricingModelBtn');
- if (addBtn) addBtn.style.display = 'block';
- } else {
- document.getElementById('pricingConfigJson').value = JSON.stringify(pricing, null, 2);
- const addBtn = document.getElementById('addPricingModelBtn');
- if (addBtn) addBtn.style.display = 'none';
- }
- }
- } catch (error) {
- console.error('Failed to load pricing:', error);
- showNotification('加载定价失败', 'error');
- } finally {
- hideLoading();
- }
-}
-
-function displayPricingTable(pricing) {
- const tbody = document.getElementById('pricingTableBody');
- if (!tbody) return;
-
- tbody.innerHTML = '';
-
- // 如果没有定价数据,添加一个空行
- if (!pricing || Object.keys(pricing).length === 0) {
- addPricingModel();
- return;
- }
-
- // 为每个模型创建一行
- Object.entries(pricing).forEach(([modelName, modelPricing]) => {
- const row = document.createElement('tr');
- const inputPrice = modelPricing.input || 0;
- const outputPrice = modelPricing.output || 0;
-
- row.innerHTML = `
-
-
- |
-
-
- |
-
-
- |
-
-
- |
- `;
- tbody.appendChild(row);
- });
-}
-
-export function addPricingModel() {
- const tbody = document.getElementById('pricingTableBody');
- if (!tbody) return;
-
- const row = document.createElement('tr');
- row.innerHTML = `
-
-
- |
-
-
- |
-
-
- |
-
-
- |
- `;
- tbody.appendChild(row);
-}
-
-export function removePricingRow(button) {
- const row = button.closest('tr');
- if (row) {
- row.remove();
- }
-}
-
-function collectPricingTableData() {
- const tbody = document.getElementById('pricingTableBody');
- const config = {};
-
- if (!tbody) return config;
-
- Array.from(tbody.children).forEach((row) => {
- const inputs = row.querySelectorAll('input');
- if (inputs.length < 3) return;
-
- const modelName = inputs[0].value.trim();
- const inputPrice = parseFloat(inputs[1].value) || 0;
- const outputPrice = parseFloat(inputs[2].value) || 0;
-
- if (modelName) {
- config[modelName] = {
- input: inputPrice,
- output: outputPrice
- };
- }
- });
-
- return config;
-}
-
-export function togglePricingEditMode() {
- if (pricingEditMode === 'table') {
- // Switch to JSON mode - collect table data and show in JSON
- const config = collectPricingTableData();
- document.getElementById('pricingConfigJson').value = JSON.stringify(config, null, 2);
- document.getElementById('pricingTableMode').style.display = 'none';
- document.getElementById('pricingJsonMode').style.display = 'block';
- document.getElementById('pricingModeToggle').textContent = '📊 切换到表格模式';
- const addBtn = document.getElementById('addPricingModelBtn');
- if (addBtn) addBtn.style.display = 'none';
- pricingEditMode = 'json';
- } else {
- // Switch to table mode - parse JSON and display table
- try {
- const jsonValue = document.getElementById('pricingConfigJson').value;
- const config = jsonValue ? JSON.parse(jsonValue) : {};
- displayPricingTable(config);
- } catch (error) {
- showNotification('JSON格式错误', 'error');
- return;
- }
- document.getElementById('pricingTableMode').style.display = 'block';
- document.getElementById('pricingJsonMode').style.display = 'none';
- document.getElementById('pricingModeToggle').textContent = '📝 切换到JSON模式';
- const addBtn = document.getElementById('addPricingModelBtn');
- if (addBtn) addBtn.style.display = 'block';
- pricingEditMode = 'table';
- }
-}
-
-export async function savePricing(event) {
- if (event) event.preventDefault();
-
- const adminToken = localStorage.getItem('adminToken');
- let config;
-
- try {
- if (pricingEditMode === 'table') {
- // Validate table data
- config = collectPricingTableData();
- if (Object.keys(config).length === 0) {
- showNotification('请至少添加一个模型定价', 'error');
- return;
- }
- } else {
- // Validate JSON
- const configText = document.getElementById('pricingConfigJson').value;
- if (!configText) {
- showNotification('请填写定价配置', 'error');
- return;
- }
- config = JSON.parse(configText);
- }
-
- // Save to API
- showLoading('保存定价配置...');
- const response = await fetch('/api/admin/pricing', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'x-admin-token': adminToken
- },
- body: JSON.stringify(config)
- });
-
- if (response.ok) {
- showNotification('定价配置已保存', 'success');
- loadPricing();
- } else {
- const error = await response.text();
- showNotification('保存失败:' + error, 'error');
- }
- } catch (error) {
- showNotification('保存失败:' + error.message, 'error');
- } finally {
- hideLoading();
- }
-}
diff --git a/public/js/tokens.js b/public/js/tokens.js
deleted file mode 100644
index d04a480..0000000
--- a/public/js/tokens.js
+++ /dev/null
@@ -1,472 +0,0 @@
-// ===== Token Management Module =====
-import { toggleModal, showNotification, showLoading, hideLoading } from './utils.js';
-
-let tokenEditMode = 'form'; // 'form' or 'json'
-let currentEditingKey = null;
-let availableChannels = []; // Store available channels for select options
-let selectedChannels = []; // Store selected channel keys
-
-// Load available channels
-async function loadAvailableChannels() {
- const adminToken = localStorage.getItem('adminToken');
- try {
- const response = await fetch('/api/admin/channel', {
- headers: { 'x-admin-token': adminToken }
- });
-
- if (response.ok) {
- const result = await response.json();
- const channels = result.data || [];
- availableChannels = channels.map(c => c.key);
- populateChannelOptions();
- }
- } catch (error) {
- console.error('Failed to load channels:', error);
- }
-}
-
-// Populate channel options in the dropdown
-function populateChannelOptions() {
- const container = document.getElementById('channelOptionsContainer');
- if (!container) return;
-
- if (availableChannels.length === 0) {
- container.innerHTML = '暂无可用频道
';
- } else {
- container.innerHTML = availableChannels.map(key => `
-
-
-
-
- `).join('');
- }
- updateChannelDisplay();
-}
-
-// Toggle channel dropdown
-export function toggleChannelDropdown() {
- const dropdown = document.getElementById('channelSelectDropdown');
- const trigger = document.getElementById('channelSelectTrigger');
-
- if (dropdown.classList.contains('hidden')) {
- dropdown.classList.remove('hidden');
- trigger.classList.add('open');
- // Close dropdown when clicking outside
- setTimeout(() => {
- document.addEventListener('click', closeChannelDropdownOnClickOutside);
- }, 0);
- } else {
- dropdown.classList.add('hidden');
- trigger.classList.remove('open');
- document.removeEventListener('click', closeChannelDropdownOnClickOutside);
- }
-}
-
-function closeChannelDropdownOnClickOutside(event) {
- const wrapper = document.querySelector('.multi-select-wrapper');
- if (wrapper && !wrapper.contains(event.target)) {
- const dropdown = document.getElementById('channelSelectDropdown');
- const trigger = document.getElementById('channelSelectTrigger');
- dropdown.classList.add('hidden');
- trigger.classList.remove('open');
- document.removeEventListener('click', closeChannelDropdownOnClickOutside);
- }
-}
-
-// Handle checkbox change event
-export function handleChannelCheckboxChange(channelKey) {
- const checkbox = document.getElementById(`channel_${channelKey}`);
- if (!checkbox) return;
-
- if (checkbox.checked) {
- // Add to selected if not already present
- if (!selectedChannels.includes(channelKey)) {
- selectedChannels.push(channelKey);
- }
- } else {
- // Remove from selected
- selectedChannels = selectedChannels.filter(k => k !== channelKey);
- }
-
- updateChannelDisplay();
-}
-
-// Toggle channel selection (deprecated, kept for compatibility)
-export function toggleChannelSelection(channelKey, event) {
- event.stopPropagation();
-
- const checkbox = document.getElementById(`channel_${channelKey}`);
- if (checkbox) {
- checkbox.checked = !checkbox.checked;
- }
-
- if (selectedChannels.includes(channelKey)) {
- selectedChannels = selectedChannels.filter(k => k !== channelKey);
- } else {
- selectedChannels.push(channelKey);
- }
-
- updateChannelDisplay();
-}
-
-// Update the display of selected channels
-function updateChannelDisplay() {
- const valueElement = document.getElementById('channelSelectValue');
- if (!valueElement) return;
-
- if (selectedChannels.length === 0) {
- valueElement.textContent = '请选择频道...';
- valueElement.classList.add('placeholder');
- } else if (selectedChannels.length === 1) {
- valueElement.textContent = selectedChannels[0];
- valueElement.classList.remove('placeholder');
- } else {
- valueElement.textContent = `已选择 ${selectedChannels.length} 个频道`;
- valueElement.classList.remove('placeholder');
- }
-}
-
-// Filter channels based on search
-export function filterChannels() {
- const searchInput = document.getElementById('channelSearchInput');
- const searchTerm = searchInput.value.toLowerCase();
- const options = document.querySelectorAll('.multi-select-option');
-
- options.forEach(option => {
- const label = option.querySelector('label').textContent.toLowerCase();
- if (label.includes(searchTerm)) {
- option.style.display = '';
- } else {
- option.style.display = 'none';
- }
- });
-}
-
-// Load tokens from API
-export async function loadTokens() {
- const adminToken = localStorage.getItem('adminToken');
- showLoading('加载令牌列表...');
- try {
- const response = await fetch('/api/admin/token', {
- headers: { 'x-admin-token': adminToken }
- });
-
- if (response.ok) {
- const result = await response.json();
- const tokens = result.data || [];
- displayTokens(tokens);
- }
- } catch (error) {
- console.error('Failed to load tokens:', error);
- showNotification('加载令牌失败', 'error');
- } finally {
- hideLoading();
- }
-}
-
-// Display tokens in the list
-function displayTokens(tokens) {
- const container = document.getElementById('tokensList');
- if (!container) return;
-
- if (!tokens || tokens.length === 0) {
- container.innerHTML = '🎫 暂无令牌数据
';
- return;
- }
-
- container.innerHTML = tokens.map(item => {
- let parsedValue = {};
- try {
- parsedValue = typeof item.value === 'string' ? JSON.parse(item.value) : item.value;
- } catch {
- parsedValue = { name: 'Invalid JSON', channel_keys: [], total_quota: 0 };
- }
-
- const name = parsedValue.name || '未命名';
- const channelKeys = parsedValue.channel_keys || [];
- const channelDisplay = channelKeys.length === 0 ? '所有频道' : channelKeys.slice(0, 2).join(', ') + (channelKeys.length > 2 ? '...' : '');
- const usage = item.usage || 0;
- const totalQuota = parsedValue.total_quota || 0;
-
- // 将配额转换为美元显示
- const totalQuotaValue = totalQuota || 0;
- const quotaInDollars = (totalQuotaValue / 1000000).toFixed(2);
- const usageValue = usage || 0;
- const usageInDollars = (usageValue / 1000000).toFixed(2);
-
- return `
-
-
-
${name}
-
- 频道: ${channelDisplay} | 配额: $${quotaInDollars} | 已用: $${usageInDollars}
-
-
-
-
-
-
-
-
- `;
- }).join('');
-}
-
-// Show token list view
-export function showTokenList() {
- document.getElementById('tokenListView').style.display = 'block';
- document.getElementById('tokenFormView').style.display = 'none';
- loadTokens();
-}
-
-// Show token form for adding
-export async function showTokenForm() {
- currentEditingKey = null;
- document.getElementById('tokenFormTitle').textContent = '添加令牌';
- document.getElementById('tokenListView').style.display = 'none';
- document.getElementById('tokenFormView').style.display = 'block';
- clearTokenForm();
- // Reset to form mode
- tokenEditMode = 'form';
- document.getElementById('tokenFormMode').style.display = 'block';
- document.getElementById('tokenJsonMode').style.display = 'none';
- document.getElementById('tokenModeToggle').textContent = '📝 切换到JSON模式';
-
- // Token key should be editable when creating
- document.getElementById('tokenKey').disabled = false;
- document.getElementById('tokenKey').placeholder = '输入令牌标识或点击生成';
-
- // Enable generate button for new tokens
- const generateBtn = document.getElementById('generateTokenBtn');
- if (generateBtn) {
- generateBtn.disabled = false;
- }
-
- // Load available channels and auto-generate token key
- await loadAvailableChannels();
- generateTokenKey();
-}
-
-// Edit existing token
-export async function editToken(key) {
- currentEditingKey = key;
- document.getElementById('tokenFormTitle').textContent = '编辑令牌';
- document.getElementById('tokenListView').style.display = 'none';
- document.getElementById('tokenFormView').style.display = 'block';
-
- showLoading('加载令牌数据...');
- try {
- // Load available channels first
- await loadAvailableChannels();
-
- const adminToken = localStorage.getItem('adminToken');
- const response = await fetch('/api/admin/token', {
- headers: { 'x-admin-token': adminToken }
- });
-
- if (response.ok) {
- const result = await response.json();
- const tokens = result.data || [];
- const token = tokens.find(t => t.key === key);
-
- if (token) {
- const config = typeof token.value === 'string' ? JSON.parse(token.value) : token.value;
- fillTokenForm(key, config, token.usage || 0);
-
- // Set token key as readonly when editing
- document.getElementById('tokenKey').disabled = true;
-
- // Disable generate button when editing
- const generateBtn = document.getElementById('generateTokenBtn');
- if (generateBtn) {
- generateBtn.disabled = true;
- }
- }
- }
- } catch (error) {
- showNotification('加载令牌数据失败', 'error');
- } finally {
- hideLoading();
- }
-}
-
-// Fill form with token data
-function fillTokenForm(key, config, usage) {
- document.getElementById('tokenKey').value = key;
- // Don't change disabled state here - it's set by editToken
- document.getElementById('tokenName').value = config.name || '';
-
- // Set selected channels
- selectedChannels = config.channel_keys && Array.isArray(config.channel_keys) ? config.channel_keys : [];
- populateChannelOptions();
-
- document.getElementById('totalQuotaInput').value = config.total_quota || 0;
-
- // Update JSON mode
- document.getElementById('tokenValueJson').value = JSON.stringify(config, null, 2);
-}
-
-// Clear form
-function clearTokenForm() {
- document.getElementById('tokenKey').value = '';
- document.getElementById('tokenKey').disabled = false;
- document.getElementById('tokenName').value = '';
-
- // Clear selected channels
- selectedChannels = [];
- updateChannelDisplay();
-
- document.getElementById('totalQuotaInput').value = '';
- document.getElementById('tokenValueJson').value = '';
-}
-
-// Set quota value (for quick buttons)
-export function setQuotaValue(value) {
- document.getElementById('totalQuotaInput').value = value;
-}
-
-// Generate random token key
-export function generateTokenKey() {
- const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
- let token = 'sk-';
- for (let i = 0; i < 48; i++) {
- token += chars.charAt(Math.floor(Math.random() * chars.length));
- }
- document.getElementById('tokenKey').value = token;
-}
-
-// Toggle between form and JSON mode
-export function toggleTokenEditMode() {
- if (tokenEditMode === 'form') {
- // Switch to JSON mode - collect form data and show in JSON
- const formData = collectTokenFormData();
- if (formData) {
- document.getElementById('tokenValueJson').value = JSON.stringify(formData, null, 2);
- }
- document.getElementById('tokenFormMode').style.display = 'none';
- document.getElementById('tokenJsonMode').style.display = 'block';
- document.getElementById('tokenModeToggle').textContent = '📋 切换到表单模式';
- tokenEditMode = 'json';
- } else {
- // Switch to form mode - parse JSON and fill form
- try {
- const jsonValue = document.getElementById('tokenValueJson').value;
- if (jsonValue) {
- const config = JSON.parse(jsonValue);
- document.getElementById('tokenName').value = config.name || '';
-
- // Set selected channels
- selectedChannels = config.channel_keys && Array.isArray(config.channel_keys) ? config.channel_keys : [];
- populateChannelOptions();
-
- document.getElementById('totalQuotaInput').value = config.total_quota || 0;
- }
- } catch (error) {
- showNotification('JSON格式错误', 'error');
- return;
- }
- document.getElementById('tokenFormMode').style.display = 'block';
- document.getElementById('tokenJsonMode').style.display = 'none';
- document.getElementById('tokenModeToggle').textContent = '📝 切换到JSON模式';
- tokenEditMode = 'form';
- }
-}
-
-// Collect form data
-function collectTokenFormData() {
- const name = document.getElementById('tokenName').value;
- const totalQuota = parseFloat(document.getElementById('totalQuotaInput').value) || 0;
-
- if (!name) {
- return null;
- }
-
- return {
- name,
- channel_keys: selectedChannels,
- total_quota: totalQuota
- };
-}
-
-// Save token (add or update)
-export async function saveToken() {
- const key = document.getElementById('tokenKey').value || currentEditingKey;
- if (!key) {
- showNotification('请填写令牌标识', 'error');
- return;
- }
-
- let config;
- if (tokenEditMode === 'form') {
- config = collectTokenFormData();
- if (!config) {
- showNotification('请填写所有必填字段(令牌名称)', 'error');
- return;
- }
- } else {
- const jsonValue = document.getElementById('tokenValueJson').value;
- if (!jsonValue) {
- showNotification('请填写配置JSON', 'error');
- return;
- }
- try {
- config = JSON.parse(jsonValue);
- } catch {
- showNotification('JSON格式错误,请检查配置格式', 'error');
- return;
- }
- }
-
- const adminToken = localStorage.getItem('adminToken');
-
- showLoading('保存令牌...');
- try {
- const response = await fetch(`/api/admin/token/${encodeURIComponent(key)}`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'x-admin-token': adminToken
- },
- body: JSON.stringify(config)
- });
-
- if (response.ok) {
- showNotification(currentEditingKey ? '令牌更新成功' : '令牌添加成功', 'success');
- clearTokenForm();
- showTokenList();
- } else {
- const error = await response.text();
- showNotification('保存失败:' + error, 'error');
- }
- } catch (error) {
- showNotification('保存失败:' + error.message, 'error');
- } finally {
- hideLoading();
- }
-}
-
-// Delete token
-export async function deleteToken(key) {
- if (!confirm(`确定要删除令牌 "${key}" 吗?`)) return;
-
- const adminToken = localStorage.getItem('adminToken');
-
- showLoading('删除令牌...');
- try {
- const response = await fetch(`/api/admin/token/${encodeURIComponent(key)}`, {
- method: 'DELETE',
- headers: { 'x-admin-token': adminToken }
- });
-
- if (response.ok) {
- showNotification('令牌已删除', 'success');
- loadTokens();
- } else {
- showNotification('删除失败', 'error');
- }
- } catch (error) {
- showNotification('删除失败:' + error.message, 'error');
- } finally {
- hideLoading();
- }
-}
diff --git a/public/js/utils.js b/public/js/utils.js
deleted file mode 100644
index 1ce4bd0..0000000
--- a/public/js/utils.js
+++ /dev/null
@@ -1,180 +0,0 @@
-// ===== Utility Functions =====
-
-// Modal Management
-export function toggleModal(modalId) {
- const modal = document.getElementById(modalId);
- modal.classList.toggle('active');
-}
-
-// Tab Switching
-export function switchTab(tabName) {
- // Update active tab content
- document.querySelectorAll('.tab-content').forEach(tab => {
- tab.classList.remove('active');
- });
- document.getElementById(tabName).classList.add('active');
-
- // Update active nav item
- document.querySelectorAll('.nav-item').forEach(item => {
- item.classList.remove('active');
- });
- const targetNav = document.querySelector(`[data-tab="${tabName}"]`);
- if (targetNav) {
- targetNav.classList.add('active');
- }
-
- // Update page title
- const titles = {
- 'dashboard': '仪表盘',
- 'api-test': 'API 测试',
- 'channels': '频道管理',
- 'tokens': '令牌管理',
- 'pricing': '定价管理',
- 'database': '数据库管理'
- };
- document.getElementById('pageTitle').textContent = titles[tabName] || tabName;
-
- // Load data for specific tabs if authenticated
- const adminToken = localStorage.getItem('adminToken');
- if (adminToken) {
- // Reset to list view and reload data for management pages
- if (tabName === 'channels') {
- window.showChannelList && window.showChannelList();
- }
- if (tabName === 'tokens') {
- window.showTokenList && window.showTokenList();
- }
- if (tabName === 'pricing') {
- window.loadPricing && window.loadPricing();
- }
- }
-}
-
-// Theme Toggle
-export function toggleTheme() {
- const isDark = document.documentElement.classList.contains('dark');
- if (isDark) {
- document.documentElement.classList.remove('dark');
- localStorage.setItem('theme', 'light');
- document.getElementById('themeIcon').textContent = '🌙';
- } else {
- document.documentElement.classList.add('dark');
- localStorage.setItem('theme', 'dark');
- document.getElementById('themeIcon').textContent = '☀️';
- }
-}
-
-// Notification System
-export function showNotification(message, type = 'info') {
- const className = type === 'success' ? 'alert-success' : (type === 'error' ? 'alert-error' : 'alert-info');
- console.log(`[${type.toUpperCase()}] ${message}`);
-
- // Create toast notification
- const toast = document.createElement('div');
- toast.className = `alert ${className}`;
- toast.style.cssText = 'position: fixed; top: 20px; right: 20px; z-index: 100; min-width: 300px; animation: slideIn 0.3s ease;';
- toast.innerHTML = `${type === 'success' ? '✅' : type === 'error' ? '❌' : 'ℹ️'} ${message}`;
- document.body.appendChild(toast);
-
- setTimeout(() => {
- toast.style.animation = 'fadeOut 0.3s ease';
- setTimeout(() => toast.remove(), 300);
- }, 3000);
-}
-
-// Copy to Clipboard
-export function copyToClipboard(text) {
- if (navigator.clipboard && navigator.clipboard.writeText) {
- navigator.clipboard.writeText(text).then(() => {
- showNotification('已复制到剪贴板', 'success');
- }).catch(err => {
- console.error('复制失败:', err);
- fallbackCopy(text);
- });
- } else {
- fallbackCopy(text);
- }
-}
-
-// Fallback copy method for older browsers
-function fallbackCopy(text) {
- const textArea = document.createElement('textarea');
- textArea.value = text;
- textArea.style.position = 'fixed';
- textArea.style.top = '0';
- textArea.style.left = '0';
- textArea.style.width = '2em';
- textArea.style.height = '2em';
- textArea.style.padding = '0';
- textArea.style.border = 'none';
- textArea.style.outline = 'none';
- textArea.style.boxShadow = 'none';
- textArea.style.background = 'transparent';
- document.body.appendChild(textArea);
- textArea.focus();
- textArea.select();
-
- try {
- const successful = document.execCommand('copy');
- if (successful) {
- showNotification('已复制到剪贴板', 'success');
- } else {
- showNotification('复制失败', 'error');
- }
- } catch (err) {
- console.error('复制失败:', err);
- showNotification('复制失败', 'error');
- }
-
- document.body.removeChild(textArea);
-}
-
-// Loading Overlay
-let loadingTimeout = null;
-
-export function showLoading(message = '加载中...') {
- const overlay = document.getElementById('loadingOverlay');
- const textEl = overlay.querySelector('.loading-text');
-
- if (textEl) {
- textEl.textContent = message;
- }
-
- // 延迟显示,避免快速请求闪烁
- clearTimeout(loadingTimeout);
- loadingTimeout = setTimeout(() => {
- overlay.classList.add('active');
- }, 100);
-}
-
-export function hideLoading() {
- clearTimeout(loadingTimeout);
- const overlay = document.getElementById('loadingOverlay');
- overlay.classList.remove('active');
-}
-
-// Fetch wrapper with automatic loading
-export async function fetchWithLoading(url, options = {}, loadingMessage = '加载中...') {
- const adminToken = localStorage.getItem('adminToken');
-
- // Add admin token to headers
- const headers = {
- ...options.headers,
- 'x-admin-token': adminToken
- };
-
- // Show loading
- showLoading(loadingMessage);
-
- try {
- const response = await fetch(url, {
- ...options,
- headers
- });
-
- return response;
- } finally {
- hideLoading();
- }
-}
-