🎨 增加并发访问

This commit is contained in:
2026-03-05 21:28:41 +08:00
commit 84c66ccaa7
114 changed files with 35396 additions and 0 deletions

480
admin-ui/bun.lock Normal file
View File

@@ -0,0 +1,480 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "kiro-admin-ui",
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.20",
"axios": "^1.13.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react-swc": "^4.2.2",
"autoprefixer": "^10.4.24",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^7.3.1",
},
},
},
"packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "https://registry.npmmirror.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.27.2.tgz", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.27.2.tgz", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="],
"@floating-ui/core": ["@floating-ui/core@1.7.4", "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.4.tgz", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.5", "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.5.tgz", { "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg=="],
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.7", "https://registry.npmmirror.com/@floating-ui/react-dom/-/react-dom-2.1.7.tgz", { "dependencies": { "@floating-ui/dom": "^1.7.5" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.10.tgz", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "https://registry.npmmirror.com/@radix-ui/primitive/-/primitive-1.1.3.tgz", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "https://registry.npmmirror.com/@radix-ui/react-context/-/react-context-1.1.2.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "https://registry.npmmirror.com/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "https://registry.npmmirror.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "https://registry.npmmirror.com/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "https://registry.npmmirror.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-id/-/react-id-1.1.1.tgz", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "https://registry.npmmirror.com/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "https://registry.npmmirror.com/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "https://registry.npmmirror.com/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "https://registry.npmmirror.com/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "https://registry.npmmirror.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "https://registry.npmmirror.com/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],
"@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.15", "https://registry.npmmirror.com/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g=="],
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "https://registry.npmmirror.com/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "https://registry.npmmirror.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "https://registry.npmmirror.com/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "https://registry.npmmirror.com/@radix-ui/rect/-/rect-1.1.1.tgz", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.47", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", {}, "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", { "os": "freebsd", "cpu": "arm64" }, "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", { "os": "linux", "cpu": "arm" }, "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", { "os": "linux", "cpu": "arm" }, "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", { "os": "linux", "cpu": "none" }, "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", { "os": "linux", "cpu": "none" }, "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", { "os": "linux", "cpu": "none" }, "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", { "os": "linux", "cpu": "none" }, "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", { "os": "openbsd", "cpu": "x64" }, "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", { "os": "none", "cpu": "arm64" }, "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="],
"@swc/core": ["@swc/core@1.15.11", "https://registry.npmmirror.com/@swc/core/-/core-1.15.11.tgz", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.25" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.15.11", "@swc/core-darwin-x64": "1.15.11", "@swc/core-linux-arm-gnueabihf": "1.15.11", "@swc/core-linux-arm64-gnu": "1.15.11", "@swc/core-linux-arm64-musl": "1.15.11", "@swc/core-linux-x64-gnu": "1.15.11", "@swc/core-linux-x64-musl": "1.15.11", "@swc/core-win32-arm64-msvc": "1.15.11", "@swc/core-win32-ia32-msvc": "1.15.11", "@swc/core-win32-x64-msvc": "1.15.11" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-iLmLTodbYxU39HhMPaMUooPwO/zqJWvsqkrXv1ZI38rMb048p6N7qtAtTp37sw9NzSrvH6oli8EdDygo09IZ/w=="],
"@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.15.11", "https://registry.npmmirror.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.11.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-QoIupRWVH8AF1TgxYyeA5nS18dtqMuxNwchjBIwJo3RdwLEFiJq6onOx9JAxHtuPwUkIVuU2Xbp+jCJ7Vzmgtg=="],
"@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.15.11", "https://registry.npmmirror.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.11.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-S52Gu1QtPSfBYDiejlcfp9GlN+NjTZBRRNsz8PNwBgSE626/FUf2PcllVUix7jqkoMC+t0rS8t+2/aSWlMuQtA=="],
"@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.15.11", "https://registry.npmmirror.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.11.tgz", { "os": "linux", "cpu": "arm" }, "sha512-lXJs8oXo6Z4yCpimpQ8vPeCjkgoHu5NoMvmJZ8qxDyU99KVdg6KwU9H79vzrmB+HfH+dCZ7JGMqMF//f8Cfvdg=="],
"@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.15.11", "https://registry.npmmirror.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.11.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-chRsz1K52/vj8Mfq/QOugVphlKPWlMh10V99qfH41hbGvwAU6xSPd681upO4bKiOr9+mRIZZW+EfJqY42ZzRyA=="],
"@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.15.11", "https://registry.npmmirror.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.11.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-PYftgsTaGnfDK4m6/dty9ryK1FbLk+LosDJ/RJR2nkXGc8rd+WenXIlvHjWULiBVnS1RsjHHOXmTS4nDhe0v0w=="],
"@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.15.11", "https://registry.npmmirror.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.11.tgz", { "os": "linux", "cpu": "x64" }, "sha512-DKtnJKIHiZdARyTKiX7zdRjiDS1KihkQWatQiCHMv+zc2sfwb4Glrodx2VLOX4rsa92NLR0Sw8WLcPEMFY1szQ=="],
"@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.15.11", "https://registry.npmmirror.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.11.tgz", { "os": "linux", "cpu": "x64" }, "sha512-mUjjntHj4+8WBaiDe5UwRNHuEzLjIWBTSGTw0JT9+C9/Yyuh4KQqlcEQ3ro6GkHmBGXBFpGIj/o5VMyRWfVfWw=="],
"@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.15.11", "https://registry.npmmirror.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.11.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-ZkNNG5zL49YpaFzfl6fskNOSxtcZ5uOYmWBkY4wVAvgbSAQzLRVBp+xArGWh2oXlY/WgL99zQSGTv7RI5E6nzA=="],
"@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.15.11", "https://registry.npmmirror.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.11.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-6XnzORkZCQzvTQ6cPrU7iaT9+i145oLwnin8JrfsLG41wl26+5cNQ2XV3zcbrnFEV6esjOceom9YO1w9mGJByw=="],
"@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.15.11", "https://registry.npmmirror.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.11.tgz", { "os": "win32", "cpu": "x64" }, "sha512-IQ2n6af7XKLL6P1gIeZACskSxK8jWtoKpJWLZmdXTDj1MGzktUy4i+FvpdtxFmJWNavRWH1VmTr6kAubRDHeKw=="],
"@swc/counter": ["@swc/counter@0.1.3", "https://registry.npmmirror.com/@swc/counter/-/counter-0.1.3.tgz", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="],
"@swc/types": ["@swc/types@0.1.25", "https://registry.npmmirror.com/@swc/types/-/types-0.1.25.tgz", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.1.18.tgz", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.1.18.tgz", { "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" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="],
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.18", "https://registry.npmmirror.com/@tailwindcss/postcss/-/postcss-4.1.18.tgz", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "postcss": "^8.4.41", "tailwindcss": "4.1.18" } }, "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.20", "https://registry.npmmirror.com/@tanstack/query-core/-/query-core-5.90.20.tgz", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.20", "https://registry.npmmirror.com/@tanstack/react-query/-/react-query-5.90.20.tgz", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw=="],
"@types/estree": ["@types/estree@1.0.8", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/react": ["@types/react@19.2.10", "https://registry.npmmirror.com/@types/react/-/react-19.2.10.tgz", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.3.tgz", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@vitejs/plugin-react-swc": ["@vitejs/plugin-react-swc@4.2.2", "https://registry.npmmirror.com/@vitejs/plugin-react-swc/-/plugin-react-swc-4.2.2.tgz", { "dependencies": { "@rolldown/pluginutils": "1.0.0-beta.47", "@swc/core": "^1.13.5" }, "peerDependencies": { "vite": "^4 || ^5 || ^6 || ^7" } }, "sha512-x+rE6tsxq/gxrEJN3Nv3dIV60lFflPj94c90b+NNo6n1QV1QQUTLoL0MpaOVasUZ0zqVBn7ead1B5ecx1JAGfA=="],
"aria-hidden": ["aria-hidden@1.2.6", "https://registry.npmmirror.com/aria-hidden/-/aria-hidden-1.2.6.tgz", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"asynckit": ["asynckit@0.4.0", "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"autoprefixer": ["autoprefixer@10.4.24", "https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.4.24.tgz", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001766", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw=="],
"axios": ["axios@1.13.4", "https://registry.npmmirror.com/axios/-/axios-1.13.4.tgz", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
"browserslist": ["browserslist@4.28.1", "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.1.tgz", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001766", "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", {}, "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"clsx": ["clsx@2.1.1", "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"combined-stream": ["combined-stream@1.0.8", "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"csstype": ["csstype@3.2.3", "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"delayed-stream": ["delayed-stream@1.0.0", "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"detect-libc": ["detect-libc@2.1.2", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"detect-node-es": ["detect-node-es@1.1.0", "https://registry.npmmirror.com/detect-node-es/-/detect-node-es-1.1.0.tgz", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"dunder-proto": ["dunder-proto@1.0.1", "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"electron-to-chromium": ["electron-to-chromium@1.5.283", "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.283.tgz", {}, "sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w=="],
"enhanced-resolve": ["enhanced-resolve@5.18.4", "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
"es-define-property": ["es-define-property@1.0.1", "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"esbuild": ["esbuild@0.27.2", "https://registry.npmmirror.com/esbuild/-/esbuild-0.27.2.tgz", { "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" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
"escalade": ["escalade@3.2.0", "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"follow-redirects": ["follow-redirects@1.15.11", "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
"form-data": ["form-data@4.0.5", "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"fraction.js": ["fraction.js@5.3.4", "https://registry.npmmirror.com/fraction.js/-/fraction.js-5.3.4.tgz", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
"fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-nonce": ["get-nonce@1.0.1", "https://registry.npmmirror.com/get-nonce/-/get-nonce-1.0.1.tgz", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"get-proto": ["get-proto@1.0.1", "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"gopd": ["gopd@1.2.0", "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-symbols": ["has-symbols@1.1.0", "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hasown": ["hasown@2.0.2", "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"jiti": ["jiti@2.6.1", "https://registry.npmmirror.com/jiti/-/jiti-2.6.1.tgz", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"lightningcss": ["lightningcss@1.30.2", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.30.2.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "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" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
"lucide-react": ["lucide-react@0.563.0", "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.563.0.tgz", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA=="],
"magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mime-db": ["mime-db@1.52.0", "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"nanoid": ["nanoid@3.3.11", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"node-releases": ["node-releases@2.0.27", "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.27.tgz", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"picocolors": ["picocolors@1.1.1", "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"postcss": ["postcss@8.5.6", "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
"react": ["react@19.2.4", "https://registry.npmmirror.com/react/-/react-19.2.4.tgz", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-dom": ["react-dom@19.2.4", "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.4.tgz", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "https://registry.npmmirror.com/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "https://registry.npmmirror.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "https://registry.npmmirror.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"rollup": ["rollup@4.57.1", "https://registry.npmmirror.com/rollup/-/rollup-4.57.1.tgz", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="],
"scheduler": ["scheduler@0.27.0", "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"sonner": ["sonner@2.0.7", "https://registry.npmmirror.com/sonner/-/sonner-2.0.7.tgz", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
"source-map-js": ["source-map-js@1.2.1", "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"tailwind-merge": ["tailwind-merge@3.4.0", "https://registry.npmmirror.com/tailwind-merge/-/tailwind-merge-3.4.0.tgz", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
"tailwindcss": ["tailwindcss@4.1.18", "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.1.18.tgz", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
"tapable": ["tapable@2.3.0", "https://registry.npmmirror.com/tapable/-/tapable-2.3.0.tgz", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"tinyglobby": ["tinyglobby@0.2.15", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "https://registry.npmmirror.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-sidecar": ["use-sidecar@1.1.3", "https://registry.npmmirror.com/use-sidecar/-/use-sidecar-1.1.3.tgz", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"vite": ["vite@7.3.1", "https://registry.npmmirror.com/vite/-/vite-7.3.1.tgz", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "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" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "https://registry.npmmirror.com/@emnapi/core/-/core-1.8.1.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.8.1.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
}
}

13
admin-ui/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kiro Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

1
admin-ui/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFBD4F"></stop><stop offset="100%" stop-color="#FF980E"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

37
admin-ui/src/App.tsx Normal file
View File

@@ -0,0 +1,37 @@
import { useState, useEffect } from 'react'
import { storage } from '@/lib/storage'
import { LoginPage } from '@/components/login-page'
import { Dashboard } from '@/components/dashboard'
import { Toaster } from '@/components/ui/sonner'
function App() {
const [isLoggedIn, setIsLoggedIn] = useState(false)
useEffect(() => {
// 检查是否已经有保存的 API Key
if (storage.getApiKey()) {
setIsLoggedIn(true)
}
}, [])
const handleLogin = () => {
setIsLoggedIn(true)
}
const handleLogout = () => {
setIsLoggedIn(false)
}
return (
<>
{isLoggedIn ? (
<Dashboard onLogout={handleLogout} />
) : (
<LoginPage onLogin={handleLogin} />
)}
<Toaster position="top-right" />
</>
)
}
export default App

View File

@@ -0,0 +1,147 @@
import axios from 'axios'
import { storage } from '@/lib/storage'
import type {
CredentialsStatusResponse,
BalanceResponse,
CachedBalancesResponse,
SuccessResponse,
SetDisabledRequest,
SetPriorityRequest,
AddCredentialRequest,
AddCredentialResponse,
CredentialStatsResponse,
CredentialAccountInfoResponse,
ImportTokenJsonRequest,
ImportTokenJsonResponse,
} from '@/types/api'
// 创建 axios 实例
const api = axios.create({
baseURL: '/api/admin',
headers: {
'Content-Type': 'application/json',
},
})
// 请求拦截器添加 API Key
api.interceptors.request.use((config) => {
const apiKey = storage.getApiKey()
if (apiKey) {
config.headers['x-api-key'] = apiKey
}
return config
})
// 获取所有凭据状态
export async function getCredentials(): Promise<CredentialsStatusResponse> {
const { data } = await api.get<CredentialsStatusResponse>('/credentials')
return data
}
// 设置凭据禁用状态
export async function setCredentialDisabled(
id: number,
disabled: boolean
): Promise<SuccessResponse> {
const { data } = await api.post<SuccessResponse>(
`/credentials/${id}/disabled`,
{ disabled } as SetDisabledRequest
)
return data
}
// 设置凭据优先级
export async function setCredentialPriority(
id: number,
priority: number
): Promise<SuccessResponse> {
const { data } = await api.post<SuccessResponse>(
`/credentials/${id}/priority`,
{ priority } as SetPriorityRequest
)
return data
}
// 重置失败计数
export async function resetCredentialFailure(
id: number
): Promise<SuccessResponse> {
const { data } = await api.post<SuccessResponse>(`/credentials/${id}/reset`)
return data
}
// 设置凭据 Region
export async function setCredentialRegion(
id: number,
region: string | null,
apiRegion: string | null
): Promise<SuccessResponse> {
const { data } = await api.post<SuccessResponse>(`/credentials/${id}/region`, {
region: region || null,
apiRegion: apiRegion || null,
})
return data
}
// 获取凭据余额
export async function getCredentialBalance(id: number): Promise<BalanceResponse> {
const { data } = await api.get<BalanceResponse>(`/credentials/${id}/balance`)
return data
}
// 获取所有凭据的缓存余额
export async function getCachedBalances(): Promise<CachedBalancesResponse> {
const { data } = await api.get<CachedBalancesResponse>('/credentials/balances/cached')
return data
}
// 获取凭据账号信息(套餐/用量/邮箱等)
export async function getCredentialAccountInfo(
id: number
): Promise<CredentialAccountInfoResponse> {
const { data } = await api.get<CredentialAccountInfoResponse>(`/credentials/${id}/account`)
return data
}
// 添加新凭据
export async function addCredential(
req: AddCredentialRequest
): Promise<AddCredentialResponse> {
const { data } = await api.post<AddCredentialResponse>('/credentials', req)
return data
}
// 删除凭据
export async function deleteCredential(id: number): Promise<SuccessResponse> {
const { data } = await api.delete<SuccessResponse>(`/credentials/${id}`)
return data
}
// 获取指定凭据统计
export async function getCredentialStats(id: number): Promise<CredentialStatsResponse> {
const { data } = await api.get<CredentialStatsResponse>(`/credentials/${id}/stats`)
return data
}
// 清空指定凭据统计
export async function resetCredentialStats(id: number): Promise<SuccessResponse> {
const { data } = await api.post<SuccessResponse>(`/credentials/${id}/stats/reset`)
return data
}
// 清空全部统计
export async function resetAllStats(): Promise<SuccessResponse> {
const { data } = await api.post<SuccessResponse>('/stats/reset')
return data
}
// 批量导入 token.json
export async function importTokenJson(
req: ImportTokenJsonRequest
): Promise<ImportTokenJsonResponse> {
const { data } = await api.post<ImportTokenJsonResponse>(
'/credentials/import-token-json',
req
)
return data
}

View File

@@ -0,0 +1,279 @@
import { useState } from 'react'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useAddCredential } from '@/hooks/use-credentials'
import { extractErrorMessage } from '@/lib/utils'
interface AddCredentialDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
type AuthMethod = 'social' | 'idc'
export function AddCredentialDialog({ open, onOpenChange }: AddCredentialDialogProps) {
const [refreshToken, setRefreshToken] = useState('')
const [authMethod, setAuthMethod] = useState<AuthMethod>('social')
const [region, setRegion] = useState('')
const [apiRegion, setApiRegion] = useState('')
const [clientId, setClientId] = useState('')
const [clientSecret, setClientSecret] = useState('')
const [priority, setPriority] = useState('0')
const [machineId, setMachineId] = useState('')
const [proxyUrl, setProxyUrl] = useState('')
const [proxyUsername, setProxyUsername] = useState('')
const [proxyPassword, setProxyPassword] = useState('')
const { mutate, isPending } = useAddCredential()
const resetForm = () => {
setRefreshToken('')
setAuthMethod('social')
setRegion('')
setApiRegion('')
setClientId('')
setClientSecret('')
setPriority('0')
setMachineId('')
setProxyUrl('')
setProxyUsername('')
setProxyPassword('')
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
// 验证必填字段
if (!refreshToken.trim()) {
toast.error('请输入 Refresh Token')
return
}
// IdC/Builder-ID/IAM 需要额外字段
if (authMethod === 'idc' && (!clientId.trim() || !clientSecret.trim())) {
toast.error('IdC/Builder-ID/IAM 认证需要填写 Client ID 和 Client Secret')
return
}
mutate(
{
refreshToken: refreshToken.trim(),
authMethod,
region: region.trim() || undefined,
apiRegion: apiRegion.trim() || undefined,
clientId: clientId.trim() || undefined,
clientSecret: clientSecret.trim() || undefined,
priority: parseInt(priority) || 0,
machineId: machineId.trim() || undefined,
proxyUrl: proxyUrl.trim() || undefined,
proxyUsername: proxyUsername.trim() || undefined,
proxyPassword: proxyPassword.trim() || undefined,
},
{
onSuccess: (data) => {
toast.success(data.message)
onOpenChange(false)
resetForm()
},
onError: (error: unknown) => {
toast.error(`添加失败: ${extractErrorMessage(error)}`)
},
}
)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col min-h-0 flex-1">
<div className="space-y-4 py-4 overflow-y-auto flex-1 pr-1">
{/* Refresh Token */}
<div className="space-y-2">
<label htmlFor="refreshToken" className="text-sm font-medium">
Refresh Token <span className="text-red-500">*</span>
</label>
<Input
id="refreshToken"
type="password"
placeholder="请输入 Refresh Token"
value={refreshToken}
onChange={(e) => setRefreshToken(e.target.value)}
disabled={isPending}
/>
</div>
{/* 认证方式 */}
<div className="space-y-2">
<label htmlFor="authMethod" className="text-sm font-medium">
</label>
<select
id="authMethod"
value={authMethod}
onChange={(e) => setAuthMethod(e.target.value as AuthMethod)}
disabled={isPending}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="social">Social</option>
<option value="idc">IdC/Builder-ID/IAM</option>
</select>
</div>
{/* Region 配置 */}
<div className="space-y-2">
<label className="text-sm font-medium">Region </label>
<div className="grid grid-cols-2 gap-2">
<div>
<Input
id="region"
placeholder="Region"
value={region}
onChange={(e) => setRegion(e.target.value)}
disabled={isPending}
/>
</div>
<div>
<Input
id="apiRegion"
placeholder="API Region可选覆盖"
value={apiRegion}
onChange={(e) => setApiRegion(e.target.value)}
disabled={isPending}
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
Region Token 使API Region API Region
</p>
</div>
{/* IdC/Builder-ID/IAM 额外字段 */}
{authMethod === 'idc' && (
<>
<div className="space-y-2">
<label htmlFor="clientId" className="text-sm font-medium">
Client ID <span className="text-red-500">*</span>
</label>
<Input
id="clientId"
placeholder="请输入 Client ID"
value={clientId}
onChange={(e) => setClientId(e.target.value)}
disabled={isPending}
/>
</div>
<div className="space-y-2">
<label htmlFor="clientSecret" className="text-sm font-medium">
Client Secret <span className="text-red-500">*</span>
</label>
<Input
id="clientSecret"
type="password"
placeholder="请输入 Client Secret"
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
disabled={isPending}
/>
</div>
</>
)}
{/* 优先级 */}
<div className="space-y-2">
<label htmlFor="priority" className="text-sm font-medium">
</label>
<Input
id="priority"
type="number"
min="0"
placeholder="数字越小优先级越高"
value={priority}
onChange={(e) => setPriority(e.target.value)}
disabled={isPending}
/>
<p className="text-xs text-muted-foreground">
0
</p>
</div>
{/* Machine ID */}
<div className="space-y-2">
<label htmlFor="machineId" className="text-sm font-medium">
Machine ID
</label>
<Input
id="machineId"
placeholder="留空使用配置中字段, 否则由刷新Token自动派生"
value={machineId}
onChange={(e) => setMachineId(e.target.value)}
disabled={isPending}
/>
<p className="text-xs text-muted-foreground">
64 使, Token自动派生
</p>
</div>
{/* 代理配置 */}
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<Input
id="proxyUrl"
placeholder='代理 URL留空使用全局配置"direct" 不使用代理)'
value={proxyUrl}
onChange={(e) => setProxyUrl(e.target.value)}
disabled={isPending}
/>
<div className="grid grid-cols-2 gap-2">
<Input
id="proxyUsername"
placeholder="代理用户名"
value={proxyUsername}
onChange={(e) => setProxyUsername(e.target.value)}
disabled={isPending}
/>
<Input
id="proxyPassword"
type="password"
placeholder="代理密码"
value={proxyPassword}
onChange={(e) => setProxyPassword(e.target.value)}
disabled={isPending}
/>
</div>
<p className="text-xs text-muted-foreground">
使 "direct" 使
</p>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? '添加中...' : '添加'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,106 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Progress } from '@/components/ui/progress'
import { useCredentialBalance } from '@/hooks/use-credentials'
import { parseError } from '@/lib/utils'
interface BalanceDialogProps {
credentialId: number | null
open: boolean
onOpenChange: (open: boolean) => void
forceRefresh?: boolean
}
export function BalanceDialog({ credentialId, open, onOpenChange, forceRefresh }: BalanceDialogProps) {
const { data: balance, isLoading, isFetching, error } = useCredentialBalance(credentialId)
const showLoading = isLoading || (forceRefresh && isFetching)
const formatDate = (timestamp: number | null) => {
if (!timestamp) return '未知'
return new Date(timestamp * 1000).toLocaleString('zh-CN')
}
const formatNumber = (num: number) => {
return num.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
#{credentialId}
</DialogTitle>
</DialogHeader>
{showLoading && (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
)}
{error && (() => {
const parsed = parseError(error)
return (
<div className="py-6 space-y-3">
<div className="flex items-center justify-center gap-2 text-red-500">
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
<span className="font-medium">{parsed.title}</span>
</div>
{parsed.detail && (
<div className="text-sm text-muted-foreground text-center px-4">
{parsed.detail}
</div>
)}
</div>
)
})()}
{balance && (
<div className="space-y-4">
{/* 订阅类型 */}
<div className="text-center">
<span className="text-lg font-semibold">
{balance.subscriptionTitle || '未知订阅类型'}
</span>
</div>
{/* 使用进度 */}
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>已使用: ${formatNumber(balance.currentUsage)}</span>
<span>限额: ${formatNumber(balance.usageLimit)}</span>
</div>
<Progress value={balance.usagePercentage} />
<div className="text-center text-sm text-muted-foreground">
{balance.usagePercentage.toFixed(1)}% 使
</div>
</div>
{/* 详细信息 */}
<div className="grid grid-cols-2 gap-4 pt-4 border-t text-sm">
<div>
<span className="text-muted-foreground"></span>
<span className="font-medium text-green-600">
${formatNumber(balance.remaining)}
</span>
</div>
<div>
<span className="text-muted-foreground"></span>
<span className="font-medium">
{formatDate(balance.nextResetAt)}
</span>
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,434 @@
import { useState } from 'react'
import { toast } from 'sonner'
import { CheckCircle2, XCircle, AlertCircle, Loader2 } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { useCredentials, useAddCredential, useDeleteCredential } from '@/hooks/use-credentials'
import { getCredentialBalance, setCredentialDisabled } from '@/api/credentials'
import { extractErrorMessage, sha256Hex } from '@/lib/utils'
interface BatchImportDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
interface CredentialInput {
refreshToken: string
clientId?: string
clientSecret?: string
region?: string
authRegion?: string
apiRegion?: string
priority?: number
machineId?: string
}
interface VerificationResult {
index: number
status: 'pending' | 'checking' | 'verifying' | 'verified' | 'duplicate' | 'failed'
error?: string
usage?: string
email?: string
credentialId?: number
rollbackStatus?: 'success' | 'failed' | 'skipped'
rollbackError?: string
}
export function BatchImportDialog({ open, onOpenChange }: BatchImportDialogProps) {
const [jsonInput, setJsonInput] = useState('')
const [importing, setImporting] = useState(false)
const [progress, setProgress] = useState({ current: 0, total: 0 })
const [currentProcessing, setCurrentProcessing] = useState<string>('')
const [results, setResults] = useState<VerificationResult[]>([])
const { data: existingCredentials } = useCredentials()
const { mutateAsync: addCredential } = useAddCredential()
const { mutateAsync: deleteCredential } = useDeleteCredential()
const rollbackCredential = async (id: number): Promise<{ success: boolean; error?: string }> => {
try {
await setCredentialDisabled(id, true)
} catch (error) {
return {
success: false,
error: `禁用失败: ${extractErrorMessage(error)}`,
}
}
try {
await deleteCredential(id)
return { success: true }
} catch (error) {
return {
success: false,
error: `删除失败: ${extractErrorMessage(error)}`,
}
}
}
const resetForm = () => {
setJsonInput('')
setProgress({ current: 0, total: 0 })
setCurrentProcessing('')
setResults([])
}
const handleBatchImport = async () => {
// 先单独解析 JSON给出精准的错误提示
let credentials: CredentialInput[]
try {
const parsed = JSON.parse(jsonInput)
credentials = Array.isArray(parsed) ? parsed : [parsed]
} catch (error) {
toast.error('JSON 格式错误: ' + extractErrorMessage(error))
return
}
if (credentials.length === 0) {
toast.error('没有可导入的凭据')
return
}
try {
setImporting(true)
setProgress({ current: 0, total: credentials.length })
// 2. 初始化结果
const initialResults: VerificationResult[] = credentials.map((_, i) => ({
index: i + 1,
status: 'pending'
}))
setResults(initialResults)
// 3. 检测重复
const existingTokenHashes = new Set(
existingCredentials?.credentials
.map(c => c.refreshTokenHash)
.filter((hash): hash is string => Boolean(hash)) || []
)
let successCount = 0
let duplicateCount = 0
let failCount = 0
let rollbackSuccessCount = 0
let rollbackFailedCount = 0
let rollbackSkippedCount = 0
// 4. 导入并验活
for (let i = 0; i < credentials.length; i++) {
const cred = credentials[i]
const token = cred.refreshToken.trim()
const tokenHash = await sha256Hex(token)
// 更新状态为检查中
setCurrentProcessing(`正在处理凭据 ${i + 1}/${credentials.length}`)
setResults(prev => {
const newResults = [...prev]
newResults[i] = { ...newResults[i], status: 'checking' }
return newResults
})
// 检查重复
if (existingTokenHashes.has(tokenHash)) {
duplicateCount++
const existingCred = existingCredentials?.credentials.find(c => c.refreshTokenHash === tokenHash)
setResults(prev => {
const newResults = [...prev]
newResults[i] = {
...newResults[i],
status: 'duplicate',
error: '该凭据已存在',
email: existingCred?.email || undefined
}
return newResults
})
setProgress({ current: i + 1, total: credentials.length })
continue
}
// 更新状态为验活中
setResults(prev => {
const newResults = [...prev]
newResults[i] = { ...newResults[i], status: 'verifying' }
return newResults
})
let addedCredId: number | null = null
try {
// 添加凭据
const clientId = cred.clientId?.trim() || undefined
const clientSecret = cred.clientSecret?.trim() || undefined
const authMethod = clientId && clientSecret ? 'idc' : 'social'
// idc 模式下必须同时提供 clientId 和 clientSecret
if (authMethod === 'social' && (clientId || clientSecret)) {
throw new Error('idc 模式需要同时提供 clientId 和 clientSecret')
}
const addedCred = await addCredential({
refreshToken: token,
authMethod,
authRegion: cred.authRegion?.trim() || cred.region?.trim() || undefined,
apiRegion: cred.apiRegion?.trim() || undefined,
clientId,
clientSecret,
priority: cred.priority || 0,
machineId: cred.machineId?.trim() || undefined,
})
addedCredId = addedCred.credentialId
// 延迟 1 秒
await new Promise(resolve => setTimeout(resolve, 1000))
// 验活
const balance = await getCredentialBalance(addedCred.credentialId)
// 验活成功
successCount++
existingTokenHashes.add(tokenHash)
setCurrentProcessing(addedCred.email ? `验活成功: ${addedCred.email}` : `验活成功: 凭据 ${i + 1}`)
setResults(prev => {
const newResults = [...prev]
newResults[i] = {
...newResults[i],
status: 'verified',
usage: `${balance.currentUsage}/${balance.usageLimit}`,
email: addedCred.email || undefined,
credentialId: addedCred.credentialId
}
return newResults
})
} catch (error) {
// 验活失败,尝试回滚(先禁用再删除)
let rollbackStatus: VerificationResult['rollbackStatus'] = 'skipped'
let rollbackError: string | undefined
if (addedCredId) {
const rollbackResult = await rollbackCredential(addedCredId)
if (rollbackResult.success) {
rollbackStatus = 'success'
rollbackSuccessCount++
} else {
rollbackStatus = 'failed'
rollbackFailedCount++
rollbackError = rollbackResult.error
}
} else {
rollbackSkippedCount++
}
failCount++
setResults(prev => {
const newResults = [...prev]
newResults[i] = {
...newResults[i],
status: 'failed',
error: extractErrorMessage(error),
email: undefined,
rollbackStatus,
rollbackError,
}
return newResults
})
}
setProgress({ current: i + 1, total: credentials.length })
}
// 显示结果
if (failCount === 0 && duplicateCount === 0) {
toast.success(`成功导入并验活 ${successCount} 个凭据`)
} else {
const failureSummary = failCount > 0
? `,失败 ${failCount} 个(已排除 ${rollbackSuccessCount},未排除 ${rollbackFailedCount},无需排除 ${rollbackSkippedCount}`
: ''
toast.info(`验活完成:成功 ${successCount} 个,重复 ${duplicateCount}${failureSummary}`)
if (rollbackFailedCount > 0) {
toast.warning(`${rollbackFailedCount} 个失败凭据回滚未完成,请手动禁用并删除`)
}
}
} catch (error) {
toast.error('导入失败: ' + extractErrorMessage(error))
} finally {
setImporting(false)
}
}
const getStatusIcon = (status: VerificationResult['status']) => {
switch (status) {
case 'pending':
return <div className="w-5 h-5 rounded-full border-2 border-gray-300" />
case 'checking':
case 'verifying':
return <Loader2 className="w-5 h-5 animate-spin text-blue-500" />
case 'verified':
return <CheckCircle2 className="w-5 h-5 text-green-500" />
case 'duplicate':
return <AlertCircle className="w-5 h-5 text-yellow-500" />
case 'failed':
return <XCircle className="w-5 h-5 text-red-500" />
}
}
const getStatusText = (result: VerificationResult) => {
switch (result.status) {
case 'pending':
return '等待中'
case 'checking':
return '检查重复...'
case 'verifying':
return '验活中...'
case 'verified':
return '验活成功'
case 'duplicate':
return '重复凭据'
case 'failed':
if (result.rollbackStatus === 'success') return '验活失败(已排除)'
if (result.rollbackStatus === 'failed') return '验活失败(未排除)'
return '验活失败(未创建)'
}
}
return (
<Dialog
open={open}
onOpenChange={(newOpen) => {
// 关闭时清空表单(但不在导入过程中清空)
if (!newOpen && !importing) {
resetForm()
}
onOpenChange(newOpen)
}}
>
<DialogContent className="sm:max-w-2xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">
JSON
</label>
<textarea
placeholder={'粘贴 JSON 格式的凭据(支持单个对象或数组)\n例如: [{"refreshToken":"...","clientId":"...","clientSecret":"...","authRegion":"us-east-1","apiRegion":"us-west-2"}]\n支持 region 字段自动映射为 authRegion'}
value={jsonInput}
onChange={(e) => setJsonInput(e.target.value)}
disabled={importing}
className="flex min-h-[200px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 font-mono"
/>
<p className="text-xs text-muted-foreground">
💡
</p>
</div>
{(importing || results.length > 0) && (
<>
{/* 进度条 */}
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>{importing ? '验活进度' : '验活完成'}</span>
<span>{progress.current} / {progress.total}</span>
</div>
<div className="w-full bg-secondary rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all"
style={{ width: `${(progress.current / progress.total) * 100}%` }}
/>
</div>
{importing && currentProcessing && (
<div className="text-xs text-muted-foreground">
{currentProcessing}
</div>
)}
</div>
{/* 统计 */}
<div className="flex gap-4 text-sm">
<span className="text-green-600 dark:text-green-400">
: {results.filter(r => r.status === 'verified').length}
</span>
<span className="text-yellow-600 dark:text-yellow-400">
: {results.filter(r => r.status === 'duplicate').length}
</span>
<span className="text-red-600 dark:text-red-400">
: {results.filter(r => r.status === 'failed').length}
</span>
</div>
{/* 结果列表 */}
<div className="border rounded-md divide-y max-h-[300px] overflow-y-auto">
{results.map((result) => (
<div key={result.index} className="p-3">
<div className="flex items-start gap-3">
{getStatusIcon(result.status)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{result.email || `凭据 #${result.index}`}
</span>
<span className="text-xs text-muted-foreground">
{getStatusText(result)}
</span>
</div>
{result.usage && (
<div className="text-xs text-muted-foreground mt-1">
: {result.usage}
</div>
)}
{result.error && (
<div className="text-xs text-red-600 dark:text-red-400 mt-1">
{result.error}
</div>
)}
{result.rollbackError && (
<div className="text-xs text-red-600 dark:text-red-400 mt-1">
: {result.rollbackError}
</div>
)}
</div>
</div>
</div>
))}
</div>
</>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
onOpenChange(false)
resetForm()
}}
disabled={importing}
>
{importing ? '验活中...' : results.length > 0 ? '关闭' : '取消'}
</Button>
{results.length === 0 && (
<Button
type="button"
onClick={handleBatchImport}
disabled={importing || !jsonInput.trim()}
>
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,152 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
export interface VerifyResult {
id: number
status: 'pending' | 'verifying' | 'success' | 'failed'
usage?: string
error?: string
}
interface BatchVerifyDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
verifying: boolean
progress: { current: number; total: number }
results: Map<number, VerifyResult>
onCancel: () => void
}
export function BatchVerifyDialog({
open,
onOpenChange,
verifying,
progress,
results,
onCancel,
}: BatchVerifyDialogProps) {
const resultsArray = Array.from(results.values())
const successCount = resultsArray.filter(r => r.status === 'success').length
const failedCount = resultsArray.filter(r => r.status === 'failed').length
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
{/* 进度显示 */}
{verifying && (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span></span>
<span>{progress.current} / {progress.total}</span>
</div>
<div className="w-full bg-secondary rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all"
style={{ width: `${(progress.current / progress.total) * 100}%` }}
/>
</div>
</div>
)}
{/* 统计信息 */}
{results.size > 0 && (
<div className="flex justify-between text-sm font-medium">
<span></span>
<span>
: {successCount} / : {failedCount}
</span>
</div>
)}
{/* 结果列表 */}
{results.size > 0 && (
<div className="max-h-[400px] overflow-y-auto border rounded-md p-2 space-y-1">
{resultsArray.map((result) => (
<div
key={result.id}
className={`text-sm p-2 rounded ${
result.status === 'success'
? 'bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300'
: result.status === 'failed'
? 'bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300'
: result.status === 'verifying'
? 'bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300'
: 'bg-gray-50 text-gray-700 dark:bg-gray-950 dark:text-gray-300'
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<span className="font-medium"> #{result.id}</span>
{result.status === 'success' && result.usage && (
<Badge variant="secondary" className="text-xs">
{result.usage}
</Badge>
)}
</div>
<span>
{result.status === 'success' && '✓'}
{result.status === 'failed' && '✗'}
{result.status === 'verifying' && '⏳'}
{result.status === 'pending' && '⋯'}
</span>
</div>
{result.error && (
<div className="text-xs mt-1 opacity-90">
: {result.error}
</div>
)}
</div>
))}
</div>
)}
{/* 提示信息 */}
{verifying && (
<p className="text-xs text-muted-foreground">
💡 2
</p>
)}
</div>
<div className="flex justify-end gap-2">
{verifying ? (
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
</Button>
<Button
type="button"
variant="destructive"
onClick={onCancel}
>
</Button>
</>
) : (
<Button
type="button"
onClick={() => onOpenChange(false)}
>
</Button>
)}
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,476 @@
import { useState } from 'react'
import { toast } from 'sonner'
import { RefreshCw, ChevronUp, ChevronDown, Wallet, Trash2, Loader2 } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import type { CredentialStatusItem, CachedBalanceInfo, BalanceResponse } from '@/types/api'
import {
useSetDisabled,
useSetPriority,
useSetRegion,
useResetFailure,
useDeleteCredential,
} from '@/hooks/use-credentials'
interface CredentialCardProps {
credential: CredentialStatusItem
cachedBalance?: CachedBalanceInfo
onViewBalance: (id: number, forceRefresh: boolean) => void
selected: boolean
onToggleSelect: () => void
balance: BalanceResponse | null
loadingBalance: boolean
}
function formatLastUsed(lastUsedAt: string | null): string {
if (!lastUsedAt) return '从未使用'
const date = new Date(lastUsedAt)
const now = new Date()
const diff = now.getTime() - date.getTime()
if (diff < 0) return '刚刚'
const seconds = Math.floor(diff / 1000)
if (seconds < 60) return `${seconds} 秒前`
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes} 分钟前`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours} 小时前`
const days = Math.floor(hours / 24)
return `${days} 天前`
}
export function CredentialCard({
credential,
cachedBalance,
onViewBalance,
selected,
onToggleSelect,
balance,
loadingBalance,
}: CredentialCardProps) {
const [editingPriority, setEditingPriority] = useState(false)
const [priorityValue, setPriorityValue] = useState(String(credential.priority))
const [editingRegion, setEditingRegion] = useState(false)
const [regionValue, setRegionValue] = useState(credential.region ?? '')
const [apiRegionValue, setApiRegionValue] = useState(credential.apiRegion ?? '')
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const setDisabled = useSetDisabled()
const setPriority = useSetPriority()
const setRegion = useSetRegion()
const resetFailure = useResetFailure()
const deleteCredential = useDeleteCredential()
const handleToggleDisabled = () => {
setDisabled.mutate(
{ id: credential.id, disabled: !credential.disabled },
{
onSuccess: (res) => {
toast.success(res.message)
},
onError: (err) => {
toast.error('操作失败: ' + (err as Error).message)
},
}
)
}
const handlePriorityChange = () => {
const newPriority = parseInt(priorityValue, 10)
if (isNaN(newPriority) || newPriority < 0) {
toast.error('优先级必须是非负整数')
return
}
setPriority.mutate(
{ id: credential.id, priority: newPriority },
{
onSuccess: (res) => {
toast.success(res.message)
setEditingPriority(false)
},
onError: (err) => {
toast.error('操作失败: ' + (err as Error).message)
},
}
)
}
const handleRegionChange = () => {
setRegion.mutate(
{
id: credential.id,
region: regionValue.trim() || null,
apiRegion: apiRegionValue.trim() || null,
},
{
onSuccess: (res) => {
toast.success(res.message)
setEditingRegion(false)
},
onError: (err) => {
toast.error('操作失败: ' + (err as Error).message)
},
}
)
}
const handleReset = () => {
resetFailure.mutate(credential.id, {
onSuccess: (res) => {
toast.success(res.message)
},
onError: (err) => {
toast.error('操作失败: ' + (err as Error).message)
},
})
}
const handleDelete = () => {
if (!credential.disabled) {
toast.error('请先禁用凭据再删除')
setShowDeleteDialog(false)
return
}
deleteCredential.mutate(credential.id, {
onSuccess: (res) => {
toast.success(res.message)
setShowDeleteDialog(false)
},
onError: (err) => {
toast.error('删除失败: ' + (err as Error).message)
},
})
}
// 格式化缓存时间(相对时间)
const formatCacheAge = (cachedAt: number) => {
const now = Date.now()
const diff = now - cachedAt
const seconds = Math.floor(diff / 1000)
if (seconds < 60) return `${seconds}秒前`
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}分钟前`
return `${Math.floor(minutes / 60)}小时前`
}
// 检查缓存是否过期(使用后端返回的 TTL
const isCacheStale = () => {
if (!cachedBalance) return true
const ageMs = Date.now() - cachedBalance.cachedAt
const ttlMs = (cachedBalance.ttlSecs ?? 60) * 1000
return ageMs > ttlMs
}
const handleViewBalance = () => {
onViewBalance(credential.id, isCacheStale())
}
return (
<>
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Checkbox
checked={selected}
onCheckedChange={onToggleSelect}
/>
<CardTitle className="text-lg flex items-center gap-2">
{credential.email || `凭据 #${credential.id}`}
{credential.disabled && (
<Badge variant="destructive"></Badge>
)}
</CardTitle>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground"></span>
<Switch
checked={!credential.disabled}
onCheckedChange={handleToggleDisabled}
disabled={setDisabled.isPending}
/>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* 信息网格 */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground"></span>
{editingPriority ? (
<div className="inline-flex items-center gap-1 ml-1">
<Input
type="number"
value={priorityValue}
onChange={(e) => setPriorityValue(e.target.value)}
className="w-16 h-7 text-sm"
min="0"
/>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={handlePriorityChange}
disabled={setPriority.isPending}
>
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={() => {
setEditingPriority(false)
setPriorityValue(String(credential.priority))
}}
>
</Button>
</div>
) : (
<span
className="font-medium cursor-pointer hover:underline ml-1"
onClick={() => setEditingPriority(true)}
>
{credential.priority}
<span className="text-xs text-muted-foreground ml-1">()</span>
</span>
)}
</div>
<div>
<span className="text-muted-foreground"></span>
<span className={credential.failureCount > 0 ? 'text-red-500 font-medium' : ''}>
{credential.failureCount}
</span>
</div>
<div>
<span className="text-muted-foreground"></span>
<span className="font-medium">
{loadingBalance ? (
<Loader2 className="inline w-3 h-3 animate-spin" />
) : balance?.subscriptionTitle || '未知'}
</span>
</div>
<div>
<span className="text-muted-foreground"></span>
<span className="font-medium">{credential.successCount}</span>
</div>
<div className="col-span-2">
<span className="text-muted-foreground"></span>
<span className="font-medium">{formatLastUsed(credential.lastUsedAt)}</span>
</div>
<div className="col-span-2">
<span className="text-muted-foreground"></span>
{loadingBalance ? (
<span className="text-sm ml-1">
<Loader2 className="inline w-3 h-3 animate-spin" /> ...
</span>
) : balance ? (
<span className="font-medium ml-1">
{balance.remaining.toFixed(2)} / {balance.usageLimit.toFixed(2)}
<span className="text-xs text-muted-foreground ml-1">
({(100 - balance.usagePercentage).toFixed(1)}% )
</span>
</span>
) : (
<span className="text-sm text-muted-foreground ml-1"></span>
)}
</div>
<div>
<span className="text-muted-foreground"></span>
{cachedBalance && cachedBalance.ttlSecs > 0 ? (
<>
<span className={`font-medium ${cachedBalance.remaining > 0 ? 'text-green-600' : 'text-red-500'}`}>
${cachedBalance.remaining.toFixed(2)}
</span>
<span className="text-xs text-muted-foreground ml-1">
({formatCacheAge(cachedBalance.cachedAt)})
</span>
</>
) : (
<span className="text-muted-foreground"></span>
)}
</div>
{credential.hasProxy && (
<div className="col-span-2">
<span className="text-muted-foreground"></span>
<span className="font-medium">{credential.proxyUrl}</span>
</div>
)}
{/* Region 配置 */}
<div className="col-span-2">
<span className="text-muted-foreground">Region</span>
{editingRegion ? (
<div className="inline-flex items-center gap-1 ml-1 flex-wrap">
<Input
placeholder="Region留空清除"
value={regionValue}
onChange={(e) => setRegionValue(e.target.value)}
className="w-32 h-7 text-sm"
/>
<Input
placeholder="API Region可选"
value={apiRegionValue}
onChange={(e) => setApiRegionValue(e.target.value)}
className="w-36 h-7 text-sm"
/>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={handleRegionChange}
disabled={setRegion.isPending}
>
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={() => {
setEditingRegion(false)
setRegionValue(credential.region ?? '')
setApiRegionValue(credential.apiRegion ?? '')
}}
>
</Button>
</div>
) : (
<span
className="font-medium cursor-pointer hover:underline ml-1"
onClick={() => {
setRegionValue(credential.region ?? '')
setApiRegionValue(credential.apiRegion ?? '')
setEditingRegion(true)
}}
>
{credential.region || '全局默认'}
{credential.apiRegion && (
<span className="text-muted-foreground ml-1">
/ API: {credential.apiRegion}
</span>
)}
<span className="text-xs text-muted-foreground ml-1">()</span>
</span>
)}
</div>
{credential.hasProfileArn && (
<div className="col-span-2">
<Badge variant="secondary"> Profile ARN</Badge>
</div>
)}
</div>
{/* 操作按钮 */}
<div className="flex flex-wrap gap-2 pt-2 border-t">
<Button
size="sm"
variant="outline"
onClick={handleReset}
disabled={resetFailure.isPending || credential.failureCount === 0}
>
<RefreshCw className="h-4 w-4 mr-1" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
const newPriority = Math.max(0, credential.priority - 1)
setPriority.mutate(
{ id: credential.id, priority: newPriority },
{
onSuccess: (res) => toast.success(res.message),
onError: (err) => toast.error('操作失败: ' + (err as Error).message),
}
)
}}
disabled={setPriority.isPending || credential.priority === 0}
>
<ChevronUp className="h-4 w-4 mr-1" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
const newPriority = credential.priority + 1
setPriority.mutate(
{ id: credential.id, priority: newPriority },
{
onSuccess: (res) => toast.success(res.message),
onError: (err) => toast.error('操作失败: ' + (err as Error).message),
}
)
}}
disabled={setPriority.isPending}
>
<ChevronDown className="h-4 w-4 mr-1" />
</Button>
<Button
size="sm"
variant="default"
onClick={handleViewBalance}
>
<Wallet className="h-4 w-4 mr-1" />
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => setShowDeleteDialog(true)}
disabled={!credential.disabled}
title={!credential.disabled ? '需要先禁用凭据才能删除' : undefined}
>
<Trash2 className="h-4 w-4 mr-1" />
</Button>
</div>
</CardContent>
</Card>
{/* 删除确认对话框 */}
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
#{credential.id}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowDeleteDialog(false)}
disabled={deleteCredential.isPending}
>
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={deleteCredential.isPending || !credential.disabled}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,700 @@
import { useState, useEffect, useRef } from 'react'
import { RefreshCw, LogOut, Moon, Sun, Server, Plus, Upload, Trash2, RotateCcw, CheckCircle2 } from 'lucide-react'
import { useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { storage } from '@/lib/storage'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { CredentialCard } from '@/components/credential-card'
import { BalanceDialog } from '@/components/balance-dialog'
import { AddCredentialDialog } from '@/components/add-credential-dialog'
import { ImportTokenJsonDialog } from '@/components/import-token-json-dialog'
import { BatchVerifyDialog, type VerifyResult } from '@/components/batch-verify-dialog'
import { useCredentials, useCachedBalances, useDeleteCredential, useResetFailure } from '@/hooks/use-credentials'
import { getCredentialBalance } from '@/api/credentials'
import { extractErrorMessage } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import type { BalanceResponse } from '@/types/api'
interface DashboardProps {
onLogout: () => void
}
export function Dashboard({ onLogout }: DashboardProps) {
const [selectedCredentialId, setSelectedCredentialId] = useState<number | null>(null)
const [balanceDialogOpen, setBalanceDialogOpen] = useState(false)
const [forceRefreshBalance, setForceRefreshBalance] = useState(false)
const [addDialogOpen, setAddDialogOpen] = useState(false)
const [importDialogOpen, setImportDialogOpen] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [verifyDialogOpen, setVerifyDialogOpen] = useState(false)
const [verifying, setVerifying] = useState(false)
const [verifyProgress, setVerifyProgress] = useState({ current: 0, total: 0 })
const [verifyResults, setVerifyResults] = useState<Map<number, VerifyResult>>(new Map())
const [balanceMap, setBalanceMap] = useState<Map<number, BalanceResponse>>(new Map())
const [loadingBalanceIds, setLoadingBalanceIds] = useState<Set<number>>(new Set())
const [queryingInfo, setQueryingInfo] = useState(false)
const [queryInfoProgress, setQueryInfoProgress] = useState({ current: 0, total: 0 })
const cancelVerifyRef = useRef(false)
const [currentPage, setCurrentPage] = useState(1)
const itemsPerPage = 12
const [darkMode, setDarkMode] = useState(() => {
if (typeof window !== 'undefined') {
return document.documentElement.classList.contains('dark')
}
return false
})
const queryClient = useQueryClient()
const { data, isLoading, error, refetch } = useCredentials()
const { data: cachedBalancesData } = useCachedBalances()
const { mutate: deleteCredential } = useDeleteCredential()
const { mutate: resetFailure } = useResetFailure()
// 构建 id -> cachedBalance 的映射
const cachedBalanceMap = new Map(
cachedBalancesData?.balances.map((b) => [b.id, b]) ?? []
)
// 计算分页
const totalPages = Math.ceil((data?.credentials.length || 0) / itemsPerPage)
const startIndex = (currentPage - 1) * itemsPerPage
const endIndex = startIndex + itemsPerPage
const currentCredentials = data?.credentials.slice(startIndex, endIndex) || []
const disabledCredentialCount = data?.credentials.filter(credential => credential.disabled).length || 0
const selectedDisabledCount = Array.from(selectedIds).filter(id => {
const credential = data?.credentials.find(c => c.id === id)
return Boolean(credential?.disabled)
}).length
// 当凭据列表变化时重置到第一页
useEffect(() => {
setCurrentPage(1)
}, [data?.credentials.length])
// 只保留当前仍存在的凭据缓存,避免删除后残留旧数据
useEffect(() => {
if (!data?.credentials) {
setBalanceMap(new Map())
setLoadingBalanceIds(new Set())
return
}
const validIds = new Set(data.credentials.map(credential => credential.id))
setBalanceMap(prev => {
const next = new Map<number, BalanceResponse>()
prev.forEach((value, id) => {
if (validIds.has(id)) {
next.set(id, value)
}
})
return next.size === prev.size ? prev : next
})
setLoadingBalanceIds(prev => {
if (prev.size === 0) {
return prev
}
const next = new Set<number>()
prev.forEach(id => {
if (validIds.has(id)) {
next.add(id)
}
})
return next.size === prev.size ? prev : next
})
}, [data?.credentials])
const toggleDarkMode = () => {
setDarkMode(!darkMode)
document.documentElement.classList.toggle('dark')
}
const handleViewBalance = (id: number, forceRefresh: boolean) => {
setSelectedCredentialId(id)
setForceRefreshBalance(forceRefresh)
if (forceRefresh) {
// 清除该凭据的余额缓存,强制重新获取
queryClient.invalidateQueries({ queryKey: ['credential-balance', id] })
}
setBalanceDialogOpen(true)
}
const handleRefresh = () => {
refetch()
toast.success('已刷新凭据列表')
}
const handleLogout = () => {
storage.removeApiKey()
queryClient.clear()
onLogout()
}
// 选择管理
const toggleSelect = (id: number) => {
const newSelected = new Set(selectedIds)
if (newSelected.has(id)) {
newSelected.delete(id)
} else {
newSelected.add(id)
}
setSelectedIds(newSelected)
}
const deselectAll = () => {
setSelectedIds(new Set())
}
// 批量删除(仅删除已禁用项)
const handleBatchDelete = async () => {
if (selectedIds.size === 0) {
toast.error('请先选择要删除的凭据')
return
}
const disabledIds = Array.from(selectedIds).filter(id => {
const credential = data?.credentials.find(c => c.id === id)
return Boolean(credential?.disabled)
})
if (disabledIds.length === 0) {
toast.error('选中的凭据中没有已禁用项')
return
}
const skippedCount = selectedIds.size - disabledIds.length
const skippedText = skippedCount > 0 ? `(将跳过 ${skippedCount} 个未禁用凭据)` : ''
if (!confirm(`确定要删除 ${disabledIds.length} 个已禁用凭据吗?此操作无法撤销。${skippedText}`)) {
return
}
let successCount = 0
let failCount = 0
for (const id of disabledIds) {
try {
await new Promise<void>((resolve, reject) => {
deleteCredential(id, {
onSuccess: () => {
successCount++
resolve()
},
onError: (err) => {
failCount++
reject(err)
}
})
})
} catch (error) {
// 错误已在 onError 中处理
}
}
const skippedResultText = skippedCount > 0 ? `,已跳过 ${skippedCount} 个未禁用凭据` : ''
if (failCount === 0) {
toast.success(`成功删除 ${successCount} 个已禁用凭据${skippedResultText}`)
} else {
toast.warning(`删除已禁用凭据:成功 ${successCount} 个,失败 ${failCount}${skippedResultText}`)
}
deselectAll()
}
// 批量恢复异常
const handleBatchResetFailure = async () => {
if (selectedIds.size === 0) {
toast.error('请先选择要恢复的凭据')
return
}
const failedIds = Array.from(selectedIds).filter(id => {
const cred = data?.credentials.find(c => c.id === id)
return cred && cred.failureCount > 0
})
if (failedIds.length === 0) {
toast.error('选中的凭据中没有失败的凭据')
return
}
let successCount = 0
let failCount = 0
for (const id of failedIds) {
try {
await new Promise<void>((resolve, reject) => {
resetFailure(id, {
onSuccess: () => {
successCount++
resolve()
},
onError: (err) => {
failCount++
reject(err)
}
})
})
} catch (error) {
// 错误已在 onError 中处理
}
}
if (failCount === 0) {
toast.success(`成功恢复 ${successCount} 个凭据`)
} else {
toast.warning(`成功 ${successCount} 个,失败 ${failCount}`)
}
deselectAll()
}
// 一键清除所有已禁用凭据
const handleClearAll = async () => {
if (!data?.credentials || data.credentials.length === 0) {
toast.error('没有可清除的凭据')
return
}
const disabledCredentials = data.credentials.filter(credential => credential.disabled)
if (disabledCredentials.length === 0) {
toast.error('没有可清除的已禁用凭据')
return
}
if (!confirm(`确定要清除所有 ${disabledCredentials.length} 个已禁用凭据吗?此操作无法撤销。`)) {
return
}
let successCount = 0
let failCount = 0
for (const credential of disabledCredentials) {
try {
await new Promise<void>((resolve, reject) => {
deleteCredential(credential.id, {
onSuccess: () => {
successCount++
resolve()
},
onError: (err) => {
failCount++
reject(err)
}
})
})
} catch (error) {
// 错误已在 onError 中处理
}
}
if (failCount === 0) {
toast.success(`成功清除所有 ${successCount} 个已禁用凭据`)
} else {
toast.warning(`清除已禁用凭据:成功 ${successCount} 个,失败 ${failCount}`)
}
deselectAll()
}
// 查询当前页凭据信息(逐个查询,避免瞬时并发)
const handleQueryCurrentPageInfo = async () => {
if (currentCredentials.length === 0) {
toast.error('当前页没有可查询的凭据')
return
}
const ids = currentCredentials
.filter(credential => !credential.disabled)
.map(credential => credential.id)
if (ids.length === 0) {
toast.error('当前页没有可查询的启用凭据')
return
}
setQueryingInfo(true)
setQueryInfoProgress({ current: 0, total: ids.length })
let successCount = 0
let failCount = 0
for (let i = 0; i < ids.length; i++) {
const id = ids[i]
setLoadingBalanceIds(prev => {
const next = new Set(prev)
next.add(id)
return next
})
try {
const balance = await getCredentialBalance(id)
successCount++
setBalanceMap(prev => {
const next = new Map(prev)
next.set(id, balance)
return next
})
} catch (error) {
failCount++
} finally {
setLoadingBalanceIds(prev => {
const next = new Set(prev)
next.delete(id)
return next
})
}
setQueryInfoProgress({ current: i + 1, total: ids.length })
}
setQueryingInfo(false)
if (failCount === 0) {
toast.success(`查询完成:成功 ${successCount}/${ids.length}`)
} else {
toast.warning(`查询完成:成功 ${successCount} 个,失败 ${failCount}`)
}
}
// 批量验活
const handleBatchVerify = async () => {
if (selectedIds.size === 0) {
toast.error('请先选择要验活的凭据')
return
}
// 初始化状态
setVerifying(true)
cancelVerifyRef.current = false
const ids = Array.from(selectedIds)
setVerifyProgress({ current: 0, total: ids.length })
let successCount = 0
// 初始化结果,所有凭据状态为 pending
const initialResults = new Map<number, VerifyResult>()
ids.forEach(id => {
initialResults.set(id, { id, status: 'pending' })
})
setVerifyResults(initialResults)
setVerifyDialogOpen(true)
// 开始验活
for (let i = 0; i < ids.length; i++) {
// 检查是否取消
if (cancelVerifyRef.current) {
toast.info('已取消验活')
break
}
const id = ids[i]
// 更新当前凭据状态为 verifying
setVerifyResults(prev => {
const newResults = new Map(prev)
newResults.set(id, { id, status: 'verifying' })
return newResults
})
try {
const balance = await getCredentialBalance(id)
successCount++
// 更新为成功状态
setVerifyResults(prev => {
const newResults = new Map(prev)
newResults.set(id, {
id,
status: 'success',
usage: `${balance.currentUsage}/${balance.usageLimit}`
})
return newResults
})
} catch (error) {
// 更新为失败状态
setVerifyResults(prev => {
const newResults = new Map(prev)
newResults.set(id, {
id,
status: 'failed',
error: extractErrorMessage(error)
})
return newResults
})
}
// 更新进度
setVerifyProgress({ current: i + 1, total: ids.length })
// 添加延迟防止封号(最后一个不需要延迟)
if (i < ids.length - 1 && !cancelVerifyRef.current) {
await new Promise(resolve => setTimeout(resolve, 2000))
}
}
setVerifying(false)
if (!cancelVerifyRef.current) {
toast.success(`验活完成:成功 ${successCount}/${ids.length}`)
}
}
// 取消验活
const handleCancelVerify = () => {
cancelVerifyRef.current = true
setVerifying(false)
}
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
<p className="text-muted-foreground">...</p>
</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<Card className="w-full max-w-md">
<CardContent className="pt-6 text-center">
<div className="text-red-500 mb-4"></div>
<p className="text-muted-foreground mb-4">{(error as Error).message}</p>
<div className="space-x-2">
<Button onClick={() => refetch()}></Button>
<Button variant="outline" onClick={handleLogout}></Button>
</div>
</CardContent>
</Card>
</div>
)
}
return (
<div className="min-h-screen bg-background">
{/* 顶部导航 */}
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-14 items-center justify-between px-4 md:px-8">
<div className="flex items-center gap-2">
<Server className="h-5 w-5" />
<span className="font-semibold">Kiro Admin</span>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={toggleDarkMode}>
{darkMode ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</Button>
<Button variant="ghost" size="icon" onClick={handleRefresh}>
<RefreshCw className="h-5 w-5" />
</Button>
<Button variant="ghost" size="icon" onClick={handleLogout}>
<LogOut className="h-5 w-5" />
</Button>
</div>
</div>
</header>
{/* 主内容 */}
<main className="container mx-auto px-4 md:px-8 py-6">
{/* 统计卡片 */}
<div className="grid gap-4 md:grid-cols-2 mb-6">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{data?.total || 0}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">{data?.available || 0}</div>
</CardContent>
</Card>
</div>
{/* 凭据列表 */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<h2 className="text-xl font-semibold"></h2>
{selectedIds.size > 0 && (
<div className="flex items-center gap-2">
<Badge variant="secondary"> {selectedIds.size} </Badge>
<Button onClick={deselectAll} size="sm" variant="ghost">
</Button>
</div>
)}
</div>
<div className="flex gap-2">
{selectedIds.size > 0 && (
<>
<Button onClick={handleBatchVerify} size="sm" variant="outline">
<CheckCircle2 className="h-4 w-4 mr-2" />
</Button>
<Button onClick={handleBatchResetFailure} size="sm" variant="outline">
<RotateCcw className="h-4 w-4 mr-2" />
</Button>
<Button
onClick={handleBatchDelete}
size="sm"
variant="destructive"
disabled={selectedDisabledCount === 0}
title={selectedDisabledCount === 0 ? '只能删除已禁用凭据' : undefined}
>
<Trash2 className="h-4 w-4 mr-2" />
</Button>
</>
)}
{verifying && !verifyDialogOpen && (
<Button onClick={() => setVerifyDialogOpen(true)} size="sm" variant="secondary">
<CheckCircle2 className="h-4 w-4 mr-2 animate-spin" />
... {verifyProgress.current}/{verifyProgress.total}
</Button>
)}
{data?.credentials && data.credentials.length > 0 && (
<Button
onClick={handleQueryCurrentPageInfo}
size="sm"
variant="outline"
disabled={queryingInfo}
>
<RefreshCw className={`h-4 w-4 mr-2 ${queryingInfo ? 'animate-spin' : ''}`} />
{queryingInfo ? `查询中... ${queryInfoProgress.current}/${queryInfoProgress.total}` : '查询信息'}
</Button>
)}
{data?.credentials && data.credentials.length > 0 && (
<Button
onClick={handleClearAll}
size="sm"
variant="outline"
className="text-destructive hover:text-destructive"
disabled={disabledCredentialCount === 0}
title={disabledCredentialCount === 0 ? '没有可清除的已禁用凭据' : undefined}
>
<Trash2 className="h-4 w-4 mr-2" />
</Button>
)}
<Button variant="outline" onClick={() => setImportDialogOpen(true)} size="sm">
<Upload className="h-4 w-4 mr-2" />
</Button>
<Button onClick={() => setAddDialogOpen(true)} size="sm">
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
</div>
{data?.credentials.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
</CardContent>
</Card>
) : (
<>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{currentCredentials.map((credential) => (
<CredentialCard
key={credential.id}
credential={credential}
cachedBalance={cachedBalanceMap.get(credential.id)}
onViewBalance={handleViewBalance}
selected={selectedIds.has(credential.id)}
onToggleSelect={() => toggleSelect(credential.id)}
balance={balanceMap.get(credential.id) || null}
loadingBalance={loadingBalanceIds.has(credential.id)}
/>
))}
</div>
{/* 分页控件 */}
{totalPages > 1 && (
<div className="flex justify-center items-center gap-4 mt-6">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
>
</Button>
<span className="text-sm text-muted-foreground">
{currentPage} / {totalPages} {data?.credentials.length}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
>
</Button>
</div>
)}
</>
)}
</div>
</main>
{/* 余额对话框 */}
<BalanceDialog
credentialId={selectedCredentialId}
open={balanceDialogOpen}
onOpenChange={(open) => {
setBalanceDialogOpen(open)
if (!open) {
setForceRefreshBalance(false)
// 关闭弹窗时刷新缓存余额,让卡片显示最新数据
queryClient.invalidateQueries({ queryKey: ['cached-balances'] })
}
}}
forceRefresh={forceRefreshBalance}
/>
{/* 添加凭据对话框 */}
<AddCredentialDialog
open={addDialogOpen}
onOpenChange={setAddDialogOpen}
/>
{/* 导入凭据对话框 */}
<ImportTokenJsonDialog
open={importDialogOpen}
onOpenChange={setImportDialogOpen}
/>
{/* 批量验活对话框 */}
<BatchVerifyDialog
open={verifyDialogOpen}
onOpenChange={setVerifyDialogOpen}
verifying={verifying}
progress={verifyProgress}
results={verifyResults}
onCancel={handleCancelVerify}
/>
</div>
)
}

View File

@@ -0,0 +1,671 @@
import { useState, useCallback, useRef } from 'react'
import { toast } from 'sonner'
import { Upload, FileJson, CheckCircle2, XCircle, AlertCircle, Loader2 } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { useImportTokenJson, useDeleteCredential } from '@/hooks/use-credentials'
import { getCredentialBalance, setCredentialDisabled } from '@/api/credentials'
import { extractErrorMessage } from '@/lib/utils'
import type { TokenJsonItem, ImportItemResult, ImportSummary } from '@/types/api'
interface ImportTokenJsonDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
type Step = 'input' | 'preview' | 'result' | 'verifying'
// 验活结果
interface VerifyItemResult {
index: number
credentialId?: number
status: 'pending' | 'verifying' | 'verified' | 'failed' | 'skipped' | 'rolled_back' | 'rollback_failed'
usage?: string
error?: string
rollbackError?: string
}
export function ImportTokenJsonDialog({ open, onOpenChange }: ImportTokenJsonDialogProps) {
const [step, setStep] = useState<Step>('input')
const [jsonText, setJsonText] = useState('')
const [parsedItems, setParsedItems] = useState<TokenJsonItem[]>([])
const [previewResults, setPreviewResults] = useState<ImportItemResult[]>([])
const [previewSummary, setPreviewSummary] = useState<ImportSummary | null>(null)
const [finalResults, setFinalResults] = useState<ImportItemResult[]>([])
const [finalSummary, setFinalSummary] = useState<ImportSummary | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [enableVerify, setEnableVerify] = useState(false)
const [verifyResults, setVerifyResults] = useState<VerifyItemResult[]>([])
const [verifyProgress, setVerifyProgress] = useState({ current: 0, total: 0 })
const [isVerifying, setIsVerifying] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const { mutate: importMutate, isPending } = useImportTokenJson()
const { mutateAsync: deleteCredential } = useDeleteCredential()
const resetState = useCallback(() => {
setStep('input')
setJsonText('')
setParsedItems([])
setPreviewResults([])
setPreviewSummary(null)
setFinalResults([])
setFinalSummary(null)
setEnableVerify(false)
setVerifyResults([])
setVerifyProgress({ current: 0, total: 0 })
setIsVerifying(false)
}, [])
const handleClose = useCallback(() => {
if (isVerifying) return // 验活中不允许关闭
onOpenChange(false)
setTimeout(resetState, 200)
}, [onOpenChange, resetState, isVerifying])
// 将 KAM 账号结构展平为 TokenJsonItem
const flattenKamAccount = useCallback((account: Record<string, unknown>): TokenJsonItem | null => {
const cred = account.credentials as Record<string, unknown> | undefined
if (!cred || typeof cred !== 'object') return null
// refreshToken 必须是非空字符串
if (typeof cred.refreshToken !== 'string' || !cred.refreshToken.trim()) return null
// 跳过 error 状态的账号
if (account.status === 'error') return null
const authMethod = cred.authMethod as string | undefined
return {
refreshToken: cred.refreshToken.trim(),
clientId: cred.clientId as string | undefined,
clientSecret: cred.clientSecret as string | undefined,
authMethod: (!authMethod && cred.clientId && cred.clientSecret) ? 'idc' : authMethod,
region: cred.region as string | undefined,
machineId: account.machineId as string | undefined,
}
}, [])
// 解析 JSON兼容 Token JSON / KAM 导出 / 批量导入格式)
const parseJson = useCallback((text: string): TokenJsonItem[] | null => {
try {
const parsed = JSON.parse(text)
let rawItems: unknown[]
// KAM 标准导出格式:{ version, accounts: [...] }
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && Array.isArray(parsed.accounts)) {
rawItems = parsed.accounts
} else if (Array.isArray(parsed)) {
rawItems = parsed
} else if (parsed && typeof parsed === 'object') {
rawItems = [parsed]
} else {
toast.error('JSON 格式无效')
return null
}
const validItems: TokenJsonItem[] = []
for (const item of rawItems) {
if (!item || typeof item !== 'object') continue
const obj = item as Record<string, unknown>
// KAM 嵌套格式:{ credentials: { refreshToken, ... } }
if (obj.credentials && typeof obj.credentials === 'object') {
const flat = flattenKamAccount(obj)
if (flat) validItems.push(flat)
continue
}
// 扁平格式:{ refreshToken, ... }
if (typeof obj.refreshToken === 'string' && obj.refreshToken.trim()) {
const tokenItem = { ...obj, refreshToken: obj.refreshToken.trim() } as TokenJsonItem
// 兼容旧批量导入的 authRegion 字段
if (!tokenItem.region && obj.authRegion) {
tokenItem.region = obj.authRegion as string
}
if (!tokenItem.authMethod && tokenItem.clientId && tokenItem.clientSecret) {
tokenItem.authMethod = 'idc'
}
validItems.push(tokenItem)
}
}
if (validItems.length === 0) {
toast.error('JSON 中没有找到有效的凭据(需要包含 refreshToken 字段)')
return null
}
return validItems
} catch {
toast.error('JSON 格式无效')
return null
}
}, [flattenKamAccount])
// 文件拖放
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const file = e.dataTransfer.files[0]
if (!file) return
if (!file.name.endsWith('.json')) {
toast.error('请上传 JSON 文件')
return
}
const reader = new FileReader()
reader.onload = (event) => setJsonText(event.target?.result as string)
reader.readAsText(file)
}, [])
// 文件选择
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (event) => setJsonText(event.target?.result as string)
reader.readAsText(file)
}, [])
// 预览dry-run
const handlePreview = useCallback(() => {
const items = parseJson(jsonText)
if (!items) return
setParsedItems(items)
importMutate(
{ dryRun: true, items },
{
onSuccess: (response) => {
setPreviewResults(response.items)
setPreviewSummary(response.summary)
setStep('preview')
},
onError: (error) => {
toast.error(`预览失败: ${extractErrorMessage(error)}`)
},
}
)
}, [jsonText, parseJson, importMutate])
// 回滚凭据(禁用 + 删除)
const rollbackCredential = async (id: number): Promise<{ success: boolean; error?: string }> => {
try {
await setCredentialDisabled(id, true)
} catch (error) {
return { success: false, error: `禁用失败: ${extractErrorMessage(error)}` }
}
try {
await deleteCredential(id)
return { success: true }
} catch (error) {
return { success: false, error: `删除失败: ${extractErrorMessage(error)}` }
}
}
// 验活流程
const runVerification = useCallback(async (results: ImportItemResult[]) => {
const addedItems = results.filter(r => r.action === 'added' && r.credentialId)
if (addedItems.length === 0) {
toast.info('没有新增凭据需要验活')
return
}
setIsVerifying(true)
setStep('verifying')
setVerifyProgress({ current: 0, total: addedItems.length })
const initialVerifyResults: VerifyItemResult[] = addedItems.map(item => ({
index: item.index,
credentialId: item.credentialId,
status: 'pending',
}))
setVerifyResults(initialVerifyResults)
let successCount = 0
let failCount = 0
for (let i = 0; i < addedItems.length; i++) {
const item = addedItems[i]
const credId = item.credentialId!
// 更新为验活中
setVerifyResults(prev => prev.map((r, idx) =>
idx === i ? { ...r, status: 'verifying' } : r
))
try {
await new Promise(resolve => setTimeout(resolve, 1000))
const balance = await getCredentialBalance(credId)
successCount++
setVerifyResults(prev => prev.map((r, idx) =>
idx === i ? { ...r, status: 'verified', usage: `${balance.currentUsage}/${balance.usageLimit}` } : r
))
} catch (error) {
failCount++
// 验活失败,回滚
const rollback = await rollbackCredential(credId)
setVerifyResults(prev => prev.map((r, idx) =>
idx === i ? {
...r,
status: rollback.success ? 'rolled_back' : 'rollback_failed',
error: extractErrorMessage(error),
rollbackError: rollback.error,
} : r
))
}
setVerifyProgress({ current: i + 1, total: addedItems.length })
}
setIsVerifying(false)
if (failCount === 0) {
toast.success(`全部 ${successCount} 个凭据验活成功`)
} else {
toast.info(`验活完成:成功 ${successCount},失败 ${failCount}`)
}
}, [deleteCredential])
// 确认导入
const handleConfirmImport = useCallback(() => {
importMutate(
{ dryRun: false, items: parsedItems },
{
onSuccess: (response) => {
setFinalResults(response.items)
setFinalSummary(response.summary)
if (enableVerify) {
// 开启验活模式:导入后自动验活
if (response.summary.added > 0) {
toast.success(`成功导入 ${response.summary.added} 个凭据,开始验活...`)
runVerification(response.items)
} else {
// 没有新增凭据,直接显示结果
setStep('result')
toast.info('没有新增凭据需要验活')
}
} else {
// 普通模式:直接显示结果
setStep('result')
if (response.summary.added > 0) {
toast.success(`成功导入 ${response.summary.added} 个凭据`)
}
}
},
onError: (error) => {
toast.error(`导入失败: ${extractErrorMessage(error)}`)
},
}
)
}, [parsedItems, importMutate, enableVerify, runVerification])
// 渲染图标
const renderActionIcon = (action: string) => {
switch (action) {
case 'added': return <CheckCircle2 className="h-4 w-4 text-green-500" />
case 'skipped': return <AlertCircle className="h-4 w-4 text-yellow-500" />
case 'invalid': return <XCircle className="h-4 w-4 text-red-500" />
default: return null
}
}
const renderActionText = (action: string) => {
switch (action) {
case 'added': return <span className="text-green-600"></span>
case 'skipped': return <span className="text-yellow-600"></span>
case 'invalid': return <span className="text-red-600"></span>
default: return action
}
}
const getVerifyStatusIcon = (status: VerifyItemResult['status']) => {
switch (status) {
case 'pending': return <div className="w-5 h-5 rounded-full border-2 border-gray-300" />
case 'verifying': return <Loader2 className="w-5 h-5 animate-spin text-blue-500" />
case 'verified': return <CheckCircle2 className="w-5 h-5 text-green-500" />
case 'failed':
case 'rollback_failed': return <XCircle className="w-5 h-5 text-red-500" />
case 'rolled_back': return <AlertCircle className="w-5 h-5 text-yellow-500" />
case 'skipped': return <AlertCircle className="w-5 h-5 text-gray-400" />
}
}
const getVerifyStatusText = (result: VerifyItemResult) => {
switch (result.status) {
case 'pending': return '等待中'
case 'verifying': return '验活中...'
case 'verified': return '验活成功'
case 'failed': return '验活失败'
case 'rolled_back': return '验活失败(已排除)'
case 'rollback_failed': return '验活失败(未排除)'
case 'skipped': return '跳过'
}
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileJson className="h-5 w-5" />
</DialogTitle>
<DialogDescription>
{step === 'input' && '粘贴或上传 JSON 文件以批量导入凭据'}
{step === 'preview' && '预览导入结果,确认后执行导入'}
{step === 'result' && '导入完成'}
{step === 'verifying' && '正在验活导入的凭据...'}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-auto py-4">
{/* Step 1: Input */}
{step === 'input' && (
<div className="space-y-4">
{/* 拖放区域 */}
<div
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
isDragging
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-muted-foreground/50'
}`}
onDragOver={(e) => { e.preventDefault(); setIsDragging(true) }}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-10 w-10 mx-auto mb-4 text-muted-foreground" />
<p className="text-sm text-muted-foreground mb-2">
JSON
</p>
<p className="text-xs text-muted-foreground">
</p>
<input
ref={fileInputRef}
type="file"
accept=".json"
className="hidden"
onChange={handleFileSelect}
/>
</div>
{/* 分隔线 */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground"></span>
</div>
</div>
{/* 文本输入 */}
<div className="space-y-2">
<label className="text-sm font-medium"> JSON</label>
<textarea
className="w-full h-48 p-3 text-sm font-mono border rounded-md bg-background resize-none focus:outline-none focus:ring-2 focus:ring-ring"
placeholder='{"refreshToken": "...", "provider": "BuilderId", ...}'
value={jsonText}
onChange={(e) => setJsonText(e.target.value)}
/>
</div>
</div>
)}
{/* Step 2: Preview */}
{step === 'preview' && previewSummary && (
<div className="space-y-4">
{/* 统计 */}
<div className="grid grid-cols-4 gap-4">
<div className="text-center p-3 bg-muted rounded-lg">
<div className="text-2xl font-bold">{previewSummary.parsed}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<div className="text-center p-3 bg-green-50 dark:bg-green-950 rounded-lg">
<div className="text-2xl font-bold text-green-600">{previewSummary.added}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<div className="text-center p-3 bg-yellow-50 dark:bg-yellow-950 rounded-lg">
<div className="text-2xl font-bold text-yellow-600">{previewSummary.skipped}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<div className="text-center p-3 bg-red-50 dark:bg-red-950 rounded-lg">
<div className="text-2xl font-bold text-red-600">{previewSummary.invalid}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
</div>
{/* 预览列表 */}
<div className="border rounded-lg overflow-hidden">
<div className="max-h-48 overflow-auto">
<table className="w-full text-sm">
<thead className="bg-muted sticky top-0">
<tr>
<th className="text-left p-2 font-medium">#</th>
<th className="text-left p-2 font-medium"></th>
<th className="text-left p-2 font-medium"></th>
<th className="text-left p-2 font-medium"></th>
</tr>
</thead>
<tbody>
{previewResults.map((item) => (
<tr key={item.index} className="border-t">
<td className="p-2">{item.index + 1}</td>
<td className="p-2 font-mono text-xs">{item.fingerprint}</td>
<td className="p-2">
<div className="flex items-center gap-1">
{renderActionIcon(item.action)}
{renderActionText(item.action)}
</div>
</td>
<td className="p-2 text-muted-foreground text-xs">
{item.reason || '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* 验活开关 */}
{previewSummary.added > 0 && (
<div className="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
<div>
<div className="text-sm font-medium"></div>
<div className="text-xs text-muted-foreground">
</div>
</div>
<Switch checked={enableVerify} onCheckedChange={setEnableVerify} />
</div>
)}
</div>
)}
{/* Step 3: Result (普通模式) */}
{step === 'result' && finalSummary && (
<div className="space-y-4">
<div className="grid grid-cols-4 gap-4">
<div className="text-center p-3 bg-muted rounded-lg">
<div className="text-2xl font-bold">{finalSummary.parsed}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<div className="text-center p-3 bg-green-50 dark:bg-green-950 rounded-lg">
<div className="text-2xl font-bold text-green-600">{finalSummary.added}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<div className="text-center p-3 bg-yellow-50 dark:bg-yellow-950 rounded-lg">
<div className="text-2xl font-bold text-yellow-600">{finalSummary.skipped}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<div className="text-center p-3 bg-red-50 dark:bg-red-950 rounded-lg">
<div className="text-2xl font-bold text-red-600">{finalSummary.invalid}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
</div>
<div className="border rounded-lg overflow-hidden">
<div className="max-h-64 overflow-auto">
<table className="w-full text-sm">
<thead className="bg-muted sticky top-0">
<tr>
<th className="text-left p-2 font-medium">#</th>
<th className="text-left p-2 font-medium"></th>
<th className="text-left p-2 font-medium"></th>
<th className="text-left p-2 font-medium"> ID</th>
</tr>
</thead>
<tbody>
{finalResults.map((item) => (
<tr key={item.index} className="border-t">
<td className="p-2">{item.index + 1}</td>
<td className="p-2 font-mono text-xs">{item.fingerprint}</td>
<td className="p-2">
<div className="flex items-center gap-1">
{renderActionIcon(item.action)}
{renderActionText(item.action)}
</div>
</td>
<td className="p-2">
{item.credentialId ? `#${item.credentialId}` : item.reason || '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* Step: Verifying (验活模式) */}
{step === 'verifying' && (
<div className="space-y-4">
{/* 进度条 */}
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>{isVerifying ? '验活进度' : '验活完成'}</span>
<span>{verifyProgress.current} / {verifyProgress.total}</span>
</div>
<div className="w-full bg-secondary rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all"
style={{ width: verifyProgress.total > 0 ? `${(verifyProgress.current / verifyProgress.total) * 100}%` : '0%' }}
/>
</div>
</div>
{/* 统计 */}
<div className="flex gap-4 text-sm">
<span className="text-green-600 dark:text-green-400">
: {verifyResults.filter(r => r.status === 'verified').length}
</span>
<span className="text-yellow-600 dark:text-yellow-400">
: {verifyResults.filter(r => r.status === 'rolled_back').length}
</span>
<span className="text-red-600 dark:text-red-400">
: {verifyResults.filter(r => r.status === 'rollback_failed').length}
</span>
</div>
{/* 结果列表 */}
<div className="border rounded-md divide-y max-h-[300px] overflow-y-auto">
{verifyResults.map((result) => (
<div key={result.index} className="p-3">
<div className="flex items-start gap-3">
{getVerifyStatusIcon(result.status)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
#{result.credentialId || result.index + 1}
</span>
<span className="text-xs text-muted-foreground">
{getVerifyStatusText(result)}
</span>
</div>
{result.usage && (
<div className="text-xs text-muted-foreground mt-1">
: {result.usage}
</div>
)}
{result.error && (
<div className="text-xs text-red-600 dark:text-red-400 mt-1">
{result.error}
</div>
)}
{result.rollbackError && (
<div className="text-xs text-red-600 dark:text-red-400 mt-1">
: {result.rollbackError}
</div>
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
<DialogFooter>
{step === 'input' && (
<>
<Button variant="outline" onClick={handleClose}>
</Button>
<Button onClick={handlePreview} disabled={!jsonText.trim() || isPending}>
{isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
...
</>
) : (
'预览'
)}
</Button>
</>
)}
{step === 'preview' && (
<>
<Button variant="outline" onClick={() => setStep('input')}>
</Button>
<Button
onClick={handleConfirmImport}
disabled={isPending || (previewSummary?.added ?? 0) === 0}
>
{isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
...
</>
) : enableVerify ? (
`导入并验活 (${previewSummary?.added ?? 0})`
) : (
`确认导入 (${previewSummary?.added ?? 0})`
)}
</Button>
</>
)}
{step === 'result' && (
<Button onClick={handleClose}></Button>
)}
{step === 'verifying' && (
<Button onClick={handleClose} disabled={isVerifying}>
{isVerifying ? '验活中...' : '完成'}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,491 @@
import { useState, useMemo } from 'react'
import { toast } from 'sonner'
import { CheckCircle2, XCircle, AlertCircle, Loader2 } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { useCredentials, useAddCredential, useDeleteCredential } from '@/hooks/use-credentials'
import { getCredentialBalance, setCredentialDisabled } from '@/api/credentials'
import { extractErrorMessage, sha256Hex } from '@/lib/utils'
interface KamImportDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
// KAM 导出 JSON 中的账号结构
interface KamAccount {
email?: string
userId?: string | null
nickname?: string
credentials: {
refreshToken: string
clientId?: string
clientSecret?: string
region?: string
authMethod?: string
startUrl?: string
}
machineId?: string
status?: string
}
interface VerificationResult {
index: number
status: 'pending' | 'checking' | 'verifying' | 'verified' | 'duplicate' | 'failed' | 'skipped'
error?: string
usage?: string
email?: string
credentialId?: number
rollbackStatus?: 'success' | 'failed' | 'skipped'
rollbackError?: string
}
// 校验元素是否为有效的 KAM 账号结构
function isValidKamAccount(item: unknown): item is KamAccount {
if (typeof item !== 'object' || item === null) return false
const obj = item as Record<string, unknown>
if (typeof obj.credentials !== 'object' || obj.credentials === null) return false
const cred = obj.credentials as Record<string, unknown>
return typeof cred.refreshToken === 'string' && cred.refreshToken.trim().length > 0
}
// 解析 KAM 导出 JSON支持单账号和多账号格式
function parseKamJson(raw: string): KamAccount[] {
const parsed = JSON.parse(raw)
let rawItems: unknown[]
// 标准 KAM 导出格式:{ version, accounts: [...] }
if (parsed.accounts && Array.isArray(parsed.accounts)) {
rawItems = parsed.accounts
}
// 兜底:如果直接是账号数组
else if (Array.isArray(parsed)) {
rawItems = parsed
}
// 单个账号对象(有 credentials 字段)
else if (parsed.credentials && typeof parsed.credentials === 'object') {
rawItems = [parsed]
}
else {
throw new Error('无法识别的 KAM JSON 格式')
}
const validAccounts = rawItems.filter(isValidKamAccount)
if (rawItems.length > 0 && validAccounts.length === 0) {
throw new Error(`${rawItems.length} 条记录,但均缺少有效的 credentials.refreshToken`)
}
if (validAccounts.length < rawItems.length) {
const skipped = rawItems.length - validAccounts.length
console.warn(`KAM 导入:跳过 ${skipped} 条缺少有效 credentials.refreshToken 的记录`)
}
return validAccounts
}
export function KamImportDialog({ open, onOpenChange }: KamImportDialogProps) {
const [jsonInput, setJsonInput] = useState('')
const [importing, setImporting] = useState(false)
const [skipErrorAccounts, setSkipErrorAccounts] = useState(true)
const [progress, setProgress] = useState({ current: 0, total: 0 })
const [currentProcessing, setCurrentProcessing] = useState<string>('')
const [results, setResults] = useState<VerificationResult[]>([])
const { data: existingCredentials } = useCredentials()
const { mutateAsync: addCredential } = useAddCredential()
const { mutateAsync: deleteCredential } = useDeleteCredential()
const rollbackCredential = async (id: number): Promise<{ success: boolean; error?: string }> => {
try {
await setCredentialDisabled(id, true)
} catch (error) {
return { success: false, error: `禁用失败: ${extractErrorMessage(error)}` }
}
try {
await deleteCredential(id)
return { success: true }
} catch (error) {
return { success: false, error: `删除失败: ${extractErrorMessage(error)}` }
}
}
const resetForm = () => {
setJsonInput('')
setProgress({ current: 0, total: 0 })
setCurrentProcessing('')
setResults([])
}
const handleImport = async () => {
// 先单独解析 JSON给出精准的错误提示
let validAccounts: KamAccount[]
try {
const accounts = parseKamJson(jsonInput)
if (accounts.length === 0) {
toast.error('没有可导入的账号')
return
}
validAccounts = accounts.filter(a => a.credentials?.refreshToken)
if (validAccounts.length === 0) {
toast.error('没有包含有效 refreshToken 的账号')
return
}
} catch (error) {
toast.error('JSON 格式错误: ' + extractErrorMessage(error))
return
}
try {
setImporting(true)
setProgress({ current: 0, total: validAccounts.length })
// 初始化结果,标记 error 状态的账号
const initialResults: VerificationResult[] = validAccounts.map((account, i) => {
if (skipErrorAccounts && account.status === 'error') {
return { index: i + 1, status: 'skipped' as const, email: account.email || account.nickname }
}
return { index: i + 1, status: 'pending' as const, email: account.email || account.nickname }
})
setResults(initialResults)
// 重复检测
const existingTokenHashes = new Set(
existingCredentials?.credentials
.map(c => c.refreshTokenHash)
.filter((hash): hash is string => Boolean(hash)) || []
)
let successCount = 0
let duplicateCount = 0
let failCount = 0
let skippedCount = 0
for (let i = 0; i < validAccounts.length; i++) {
const account = validAccounts[i]
// 跳过 error 状态的账号
if (skipErrorAccounts && account.status === 'error') {
skippedCount++
setProgress({ current: i + 1, total: validAccounts.length })
continue
}
const cred = account.credentials
const token = cred.refreshToken.trim()
const tokenHash = await sha256Hex(token)
setCurrentProcessing(`正在处理 ${account.email || account.nickname || `账号 ${i + 1}`}`)
setResults(prev => {
const next = [...prev]
next[i] = { ...next[i], status: 'checking' }
return next
})
// 检查重复
if (existingTokenHashes.has(tokenHash)) {
duplicateCount++
const existingCred = existingCredentials?.credentials.find(c => c.refreshTokenHash === tokenHash)
setResults(prev => {
const next = [...prev]
next[i] = { ...next[i], status: 'duplicate', error: '该凭据已存在', email: existingCred?.email || account.email }
return next
})
setProgress({ current: i + 1, total: validAccounts.length })
continue
}
// 验活中
setResults(prev => {
const next = [...prev]
next[i] = { ...next[i], status: 'verifying' }
return next
})
let addedCredId: number | null = null
try {
const clientId = cred.clientId?.trim() || undefined
const clientSecret = cred.clientSecret?.trim() || undefined
const authMethod = clientId && clientSecret ? 'idc' : 'social'
// idc 模式下必须同时提供 clientId 和 clientSecret
if (authMethod === 'social' && (clientId || clientSecret)) {
throw new Error('idc 模式需要同时提供 clientId 和 clientSecret')
}
const addedCred = await addCredential({
refreshToken: token,
authMethod,
authRegion: cred.region?.trim() || undefined,
clientId,
clientSecret,
machineId: account.machineId?.trim() || undefined,
})
addedCredId = addedCred.credentialId
await new Promise(resolve => setTimeout(resolve, 1000))
const balance = await getCredentialBalance(addedCred.credentialId)
successCount++
existingTokenHashes.add(tokenHash)
setCurrentProcessing(`验活成功: ${addedCred.email || account.email || `账号 ${i + 1}`}`)
setResults(prev => {
const next = [...prev]
next[i] = {
...next[i],
status: 'verified',
usage: `${balance.currentUsage}/${balance.usageLimit}`,
email: addedCred.email || account.email,
credentialId: addedCred.credentialId,
}
return next
})
} catch (error) {
let rollbackStatus: VerificationResult['rollbackStatus'] = 'skipped'
let rollbackError: string | undefined
if (addedCredId) {
const result = await rollbackCredential(addedCredId)
if (result.success) {
rollbackStatus = 'success'
} else {
rollbackStatus = 'failed'
rollbackError = result.error
}
}
failCount++
setResults(prev => {
const next = [...prev]
next[i] = {
...next[i],
status: 'failed',
error: extractErrorMessage(error),
rollbackStatus,
rollbackError,
}
return next
})
}
setProgress({ current: i + 1, total: validAccounts.length })
}
// 汇总
const parts: string[] = []
if (successCount > 0) parts.push(`成功 ${successCount}`)
if (duplicateCount > 0) parts.push(`重复 ${duplicateCount}`)
if (failCount > 0) parts.push(`失败 ${failCount}`)
if (skippedCount > 0) parts.push(`跳过 ${skippedCount}`)
if (failCount === 0 && duplicateCount === 0 && skippedCount === 0) {
toast.success(`成功导入并验活 ${successCount} 个凭据`)
} else {
toast.info(`导入完成:${parts.join('')}`)
}
} catch (error) {
toast.error('导入失败: ' + extractErrorMessage(error))
} finally {
setImporting(false)
}
}
const getStatusIcon = (status: VerificationResult['status']) => {
switch (status) {
case 'pending':
return <div className="w-5 h-5 rounded-full border-2 border-gray-300" />
case 'checking':
case 'verifying':
return <Loader2 className="w-5 h-5 animate-spin text-blue-500" />
case 'verified':
return <CheckCircle2 className="w-5 h-5 text-green-500" />
case 'duplicate':
return <AlertCircle className="w-5 h-5 text-yellow-500" />
case 'skipped':
return <AlertCircle className="w-5 h-5 text-gray-400" />
case 'failed':
return <XCircle className="w-5 h-5 text-red-500" />
}
}
const getStatusText = (result: VerificationResult) => {
switch (result.status) {
case 'pending': return '等待中'
case 'checking': return '检查重复...'
case 'verifying': return '验活中...'
case 'verified': return '验活成功'
case 'duplicate': return '重复凭据'
case 'skipped': return '已跳过error 状态)'
case 'failed':
if (result.rollbackStatus === 'success') return '验活失败(已排除)'
if (result.rollbackStatus === 'failed') return '验活失败(未排除)'
return '验活失败(未创建)'
}
}
// 预览解析结果
const { previewAccounts, parseError } = useMemo(() => {
if (!jsonInput.trim()) return { previewAccounts: [] as KamAccount[], parseError: '' }
try {
return { previewAccounts: parseKamJson(jsonInput), parseError: '' }
} catch (e) {
return { previewAccounts: [] as KamAccount[], parseError: extractErrorMessage(e) }
}
}, [jsonInput])
const errorAccountCount = previewAccounts.filter(a => a.status === 'error').length
return (
<Dialog
open={open}
onOpenChange={(newOpen) => {
if (!newOpen && importing) return
if (!newOpen) resetForm()
onOpenChange(newOpen)
}}
>
<DialogContent className="sm:max-w-2xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>KAM </DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">KAM JSON</label>
<textarea
placeholder={'粘贴 Kiro Account Manager 导出的 JSON格式如下\n{\n "version": "1.5.0",\n "accounts": [\n {\n "email": "...",\n "credentials": {\n "refreshToken": "...",\n "clientId": "...",\n "clientSecret": "...",\n "region": "us-east-1"\n }\n }\n ]\n}'}
value={jsonInput}
onChange={(e) => setJsonInput(e.target.value)}
disabled={importing}
className="flex min-h-[200px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 font-mono"
/>
</div>
{/* 解析预览 */}
{parseError && (
<div className="text-sm text-red-600 dark:text-red-400">: {parseError}</div>
)}
{previewAccounts.length > 0 && !importing && results.length === 0 && (
<div className="space-y-2">
<div className="text-sm text-muted-foreground">
{previewAccounts.length}
{errorAccountCount > 0 && `(其中 ${errorAccountCount} 个为 error 状态)`}
</div>
{errorAccountCount > 0 && (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={skipErrorAccounts}
onChange={(e) => setSkipErrorAccounts(e.target.checked)}
className="rounded border-gray-300"
/>
error
</label>
)}
</div>
)}
{/* 导入进度和结果 */}
{(importing || results.length > 0) && (
<>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>{importing ? '导入进度' : '导入完成'}</span>
<span>{progress.current} / {progress.total}</span>
</div>
<div className="w-full bg-secondary rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all"
style={{ width: `${progress.total > 0 ? (progress.current / progress.total) * 100 : 0}%` }}
/>
</div>
{importing && currentProcessing && (
<div className="text-xs text-muted-foreground">{currentProcessing}</div>
)}
</div>
<div className="flex gap-4 text-sm">
<span className="text-green-600 dark:text-green-400">
: {results.filter(r => r.status === 'verified').length}
</span>
<span className="text-yellow-600 dark:text-yellow-400">
: {results.filter(r => r.status === 'duplicate').length}
</span>
<span className="text-red-600 dark:text-red-400">
: {results.filter(r => r.status === 'failed').length}
</span>
<span className="text-gray-500">
: {results.filter(r => r.status === 'skipped').length}
</span>
</div>
<div className="border rounded-md divide-y max-h-[300px] overflow-y-auto">
{results.map((result) => (
<div key={result.index} className="p-3">
<div className="flex items-start gap-3">
{getStatusIcon(result.status)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{result.email || `账号 #${result.index}`}
</span>
<span className="text-xs text-muted-foreground">
{getStatusText(result)}
</span>
</div>
{result.usage && (
<div className="text-xs text-muted-foreground mt-1">: {result.usage}</div>
)}
{result.error && (
<div className="text-xs text-red-600 dark:text-red-400 mt-1">{result.error}</div>
)}
{result.rollbackError && (
<div className="text-xs text-red-600 dark:text-red-400 mt-1">: {result.rollbackError}</div>
)}
</div>
</div>
</div>
))}
</div>
</>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => { onOpenChange(false); resetForm() }}
disabled={importing}
>
{importing ? '导入中...' : results.length > 0 ? '关闭' : '取消'}
</Button>
{results.length === 0 && (
<Button
type="button"
onClick={handleImport}
disabled={importing || !jsonInput.trim() || previewAccounts.length === 0 || !!parseError}
>
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,62 @@
import { useState, useEffect } from 'react'
import { KeyRound } from 'lucide-react'
import { storage } from '@/lib/storage'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
interface LoginPageProps {
onLogin: (apiKey: string) => void
}
export function LoginPage({ onLogin }: LoginPageProps) {
const [apiKey, setApiKey] = useState('')
useEffect(() => {
// 从 storage 读取保存的 API Key
const savedKey = storage.getApiKey()
if (savedKey) {
setApiKey(savedKey)
}
}, [])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (apiKey.trim()) {
storage.setApiKey(apiKey.trim())
onLogin(apiKey.trim())
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<KeyRound className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-2xl">Kiro Admin</CardTitle>
<CardDescription>
Admin API Key 访
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Input
type="password"
placeholder="Admin API Key"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="text-center"
/>
</div>
<Button type="submit" className="w-full" disabled={!apiKey.trim()}>
</Button>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -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-500/80',
warning:
'border-transparent bg-yellow-500 text-white hover:bg-yellow-500/80',
},
},
defaultVariants: {
variant: 'default',
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -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<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }

View File

@@ -0,0 +1,78 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-lg border bg-card text-card-foreground shadow-sm',
className
)}
{...props}
/>
))
Card.displayName = 'Card'
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
))
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
'text-2xl font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
))
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
))
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
))
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,28 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -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<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only"></span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-1.5 text-center sm:text-left',
className
)}
{...props}
/>
)
DialogHeader.displayName = 'DialogHeader'
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
)
DialogFooter.displayName = 'DialogFooter'
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,24 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = 'Input'
export { Input }

View File

@@ -0,0 +1,35 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
value?: number
max?: number
}
const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
({ className, value = 0, max = 100, ...props }, ref) => {
const percentage = Math.min(Math.max((value / max) * 100, 0), 100)
return (
<div
ref={ref}
className={cn(
'relative h-4 w-full overflow-hidden rounded-full bg-secondary',
className
)}
{...props}
>
<div
className={cn(
'h-full transition-all',
percentage > 80 ? 'bg-red-500' : percentage > 60 ? 'bg-yellow-500' : 'bg-green-500'
)}
style={{ width: `${percentage}%` }}
/>
</div>
)
}
)
Progress.displayName = 'Progress'
export { Progress }

View File

@@ -0,0 +1,25 @@
import { Toaster as Sonner } from 'sonner'
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
className="toaster group"
toastOptions={{
classNames: {
toast:
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: 'group-[.toast]:text-muted-foreground',
actionButton:
'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton:
'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
},
}}
{...props}
/>
)
}
export { Toaster }

View File

@@ -0,0 +1,26 @@
import * as React from 'react'
import * as SwitchPrimitives from '@radix-ui/react-switch'
import { cn } from '@/lib/utils'
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@@ -0,0 +1,175 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
getCredentials,
deleteCredential,
setCredentialDisabled,
setCredentialPriority,
setCredentialRegion,
resetCredentialFailure,
getCredentialBalance,
getCachedBalances,
getCredentialAccountInfo,
addCredential,
getCredentialStats,
resetCredentialStats,
resetAllStats,
importTokenJson,
} from '@/api/credentials'
import type { AddCredentialRequest, ImportTokenJsonRequest } from '@/types/api'
// 查询凭据列表
export function useCredentials() {
return useQuery({
queryKey: ['credentials'],
queryFn: getCredentials,
refetchInterval: 30000, // 每 30 秒刷新一次
})
}
// 查询凭据余额
export function useCredentialBalance(id: number | null) {
return useQuery({
queryKey: ['credential-balance', id],
queryFn: () => getCredentialBalance(id!),
enabled: id !== null,
retry: false, // 余额查询失败时不重试(避免重复请求被封禁的账号)
})
}
// 查询所有凭据的缓存余额(定时轮询,带退避策略)
export function useCachedBalances() {
return useQuery({
queryKey: ['cached-balances'],
queryFn: getCachedBalances,
refetchInterval: (query) => (query.state.error ? 60000 : 30000),
refetchIntervalInBackground: false, // 页面不可见时暂停轮询
})
}
// 查询凭据账号信息(套餐/用量/邮箱等)
export function useCredentialAccountInfo(id: number | null, enabled: boolean) {
return useQuery({
queryKey: ['credential-account', id],
queryFn: () => getCredentialAccountInfo(id!),
enabled: enabled && id !== null,
retry: false,
})
}
// 删除指定凭据
export function useDeleteCredential() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: number) => deleteCredential(id),
onSuccess: (_res, id) => {
queryClient.invalidateQueries({ queryKey: ['credentials'] })
queryClient.invalidateQueries({ queryKey: ['credential-balance', id] })
queryClient.invalidateQueries({ queryKey: ['credential-account', id] })
queryClient.invalidateQueries({ queryKey: ['credential-stats', id] })
},
})
}
// 设置禁用状态
export function useSetDisabled() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, disabled }: { id: number; disabled: boolean }) =>
setCredentialDisabled(id, disabled),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['credentials'] })
},
})
}
// 设置优先级
export function useSetPriority() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, priority }: { id: number; priority: number }) =>
setCredentialPriority(id, priority),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['credentials'] })
},
})
}
// 设置 Region
export function useSetRegion() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, region, apiRegion }: { id: number; region: string | null; apiRegion: string | null }) =>
setCredentialRegion(id, region, apiRegion),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['credentials'] })
},
})
}
// 重置失败计数
export function useResetFailure() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: number) => resetCredentialFailure(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['credentials'] })
},
})
}
// 添加新凭据
export function useAddCredential() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (req: AddCredentialRequest) => addCredential(req),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['credentials'] })
},
})
}
// 查询指定凭据统计
export function useCredentialStats(id: number | null, enabled: boolean) {
return useQuery({
queryKey: ['credential-stats', id],
queryFn: () => getCredentialStats(id!),
enabled: enabled && id !== null,
retry: false,
})
}
// 清空指定凭据统计
export function useResetCredentialStats() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: number) => resetCredentialStats(id),
onSuccess: (_res, id) => {
queryClient.invalidateQueries({ queryKey: ['credentials'] })
queryClient.invalidateQueries({ queryKey: ['credential-stats', id] })
},
})
}
// 清空全部统计
export function useResetAllStats() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => resetAllStats(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['credentials'] })
queryClient.invalidateQueries({ queryKey: ['credential-stats'] })
},
})
}
// 批量导入 token.json
export function useImportTokenJson() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (req: ImportTokenJsonRequest) => importTokenJson(req),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['credentials'] })
queryClient.invalidateQueries({ queryKey: ['cached-balances'] })
},
})
}

90
admin-ui/src/index.css Normal file
View File

@@ -0,0 +1,90 @@
@import "tailwindcss";
/* 自定义主题配置 */
@theme {
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
}
: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: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--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: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--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: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--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: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
* {
border-color: hsl(var(--border));
}
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
font-feature-settings: "rlig" 1, "calt" 1;
}

View File

@@ -0,0 +1,44 @@
export function formatCompactNumber(value: number | null | undefined): string {
if (value === null || value === undefined) return '-'
if (!Number.isFinite(value)) return String(value)
const abs = Math.abs(value)
const sign = value < 0 ? '-' : ''
const formatScaled = (scale: number, suffix: string) => {
const scaled = abs / scale
const decimals = scaled < 10 ? 1 : 0
const fixed = scaled.toFixed(decimals)
const trimmed = fixed.endsWith('.0') ? fixed.slice(0, -2) : fixed
return `${sign}${trimmed}${suffix}`
}
if (abs >= 1_000_000_000) return formatScaled(1_000_000_000, 'B')
if (abs >= 1_000_000) return formatScaled(1_000_000, 'M')
if (abs >= 1_000) return formatScaled(1_000, 'K')
// 小于 1000按整数显示
return `${sign}${Math.round(abs)}`
}
export function formatTokensPair(inputTokens: number, outputTokens: number): string {
return `${formatCompactNumber(inputTokens)} in / ${formatCompactNumber(outputTokens)} out`
}
export function formatExpiry(expiresAt: string | null): string {
if (!expiresAt) return '未知'
const date = new Date(expiresAt)
if (isNaN(date.getTime())) return expiresAt
const now = new Date()
const diff = date.getTime() - now.getTime()
if (diff < 0) return '已过期'
const minutes = Math.floor(diff / 60000)
if (minutes < 60) return `${minutes} 分钟`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours} 小时`
return `${Math.floor(hours / 24)}`
}

View File

@@ -0,0 +1,7 @@
const API_KEY_STORAGE_KEY = 'adminApiKey'
export const storage = {
getApiKey: () => localStorage.getItem(API_KEY_STORAGE_KEY),
setApiKey: (key: string) => localStorage.setItem(API_KEY_STORAGE_KEY, key),
removeApiKey: () => localStorage.removeItem(API_KEY_STORAGE_KEY),
}

202
admin-ui/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,202 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/**
* 解析后端错误响应,提取用户友好的错误信息
*/
export interface ParsedError {
/** 简短的错误标题 */
title: string
/** 详细的错误描述 */
detail?: string
/** 错误类型 */
type?: string
}
/**
* 从错误对象中提取错误消息
* 支持 Axios 错误和普通 Error 对象
*/
export function extractErrorMessage(error: unknown): string {
const parsed = parseError(error)
return parsed.title
}
/**
* 解析错误,返回结构化的错误信息
*/
export function parseError(error: unknown): ParsedError {
if (!error || typeof error !== 'object') {
return { title: '未知错误' }
}
const axiosError = error as Record<string, unknown>
const response = axiosError.response as Record<string, unknown> | undefined
const data = response?.data as Record<string, unknown> | undefined
const errorObj = data?.error as Record<string, unknown> | undefined
// 尝试从后端错误响应中提取信息
if (errorObj && typeof errorObj.message === 'string') {
const message = errorObj.message
const type = typeof errorObj.type === 'string' ? errorObj.type : undefined
// 解析嵌套的错误信息(如:上游服务错误: 权限不足: 403 {...}
const parsed = parseNestedErrorMessage(message)
return {
title: parsed.title,
detail: parsed.detail,
type,
}
}
// 回退到 Error.message
if ('message' in axiosError && typeof axiosError.message === 'string') {
return { title: axiosError.message }
}
return { title: '未知错误' }
}
/**
* 解析嵌套的错误消息
* 例如:"上游服务错误: 权限不足,无法获取使用额度: 403 Forbidden {...}"
*/
function parseNestedErrorMessage(message: string): { title: string; detail?: string } {
// 尝试提取 HTTP 状态码(如 403、502 等)
const statusMatch = message.match(/(\d{3})\s+\w+/)
const statusCode = statusMatch ? statusMatch[1] : null
// 尝试提取 JSON 中的 message 字段
const jsonMatch = message.match(/\{[^{}]*"message"\s*:\s*"([^"]+)"[^{}]*\}/)
if (jsonMatch) {
const innerMessage = jsonMatch[1]
// 提取主要错误原因(去掉前缀)
const parts = message.split(':').map(s => s.trim())
const mainReason = parts.length > 1 ? parts[1].split(':')[0] : parts[0]
// 在 title 中包含状态码
const title = statusCode
? `${mainReason || '服务错误'} (${statusCode})`
: (mainReason || '服务错误')
return {
title,
detail: innerMessage,
}
}
// 尝试按冒号分割,提取主要信息
const colonParts = message.split(':')
if (colonParts.length >= 2) {
const mainPart = colonParts[1].trim().split(':')[0].trim()
const title = statusCode ? `${mainPart} (${statusCode})` : mainPart
return {
title,
detail: colonParts.slice(2).join(':').trim() || undefined,
}
}
return { title: message }
}
/**
* 计算字符串的 SHA-256 哈希(十六进制)
*
* 优先使用 Web Crypto APIcrypto.subtle在非安全上下文HTTP + 非 localhost
* 自动回退到纯 JS 实现,解决 Docker 部署时 crypto.subtle 不可用的问题。
*/
export async function sha256Hex(value: string): Promise<string> {
const encoded = new TextEncoder().encode(value)
// 安全上下文中使用原生 Web Crypto API性能更好
if (typeof crypto !== 'undefined' && crypto.subtle) {
try {
const digest = await crypto.subtle.digest('SHA-256', encoded)
const bytes = new Uint8Array(digest)
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
} catch {
// digest 可能在某些策略/上下文下抛错,回退到纯 JS 实现
}
}
// 非安全上下文 fallback纯 JS SHA-256 实现
return sha256Pure(encoded)
}
/**
* 纯 JS SHA-256 实现(无外部依赖)
* 仅在 crypto.subtle 不可用时使用
*/
function sha256Pure(data: Uint8Array): string {
// SHA-256 常量:前 64 个素数的立方根的小数部分
const K = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
])
const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n))
// 预处理:填充消息
const bitLen = data.length * 8
// 消息 + 1 字节 0x80 + 填充 + 8 字节长度,总长度对齐到 64 字节
const padLen = (((data.length + 9 + 63) >>> 6) << 6)
const buf = new Uint8Array(padLen)
buf.set(data)
buf[data.length] = 0x80
// 写入 64 位大端长度(仅低 32 位,高 32 位在 JS 中始终为 0
const view = new DataView(buf.buffer)
view.setUint32(padLen - 4, bitLen, false)
// 初始哈希值
let h0 = 0x6a09e667, h1 = 0xbb67ae85, h2 = 0x3c6ef372, h3 = 0xa54ff53a
let h4 = 0x510e527f, h5 = 0x9b05688c, h6 = 0x1f83d9ab, h7 = 0x5be0cd19
const w = new Uint32Array(64)
for (let offset = 0; offset < padLen; offset += 64) {
for (let i = 0; i < 16; i++) {
w[i] = view.getUint32(offset + i * 4, false)
}
for (let i = 16; i < 64; i++) {
const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3)
const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10)
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0
}
let a = h0, b = h1, c = h2, d = h3, e = h4, f = h5, g = h6, h = h7
for (let i = 0; i < 64; i++) {
const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)
const ch = (e & f) ^ (~e & g)
const temp1 = (h + S1 + ch + K[i] + w[i]) | 0
const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)
const maj = (a & b) ^ (a & c) ^ (b & c)
const temp2 = (S0 + maj) | 0
h = g; g = f; f = e; e = (d + temp1) | 0
d = c; c = b; b = a; a = (temp1 + temp2) | 0
}
h0 = (h0 + a) | 0; h1 = (h1 + b) | 0; h2 = (h2 + c) | 0; h3 = (h3 + d) | 0
h4 = (h4 + e) | 0; h5 = (h5 + f) | 0; h6 = (h6 + g) | 0; h7 = (h7 + h) | 0
}
return [h0, h1, h2, h3, h4, h5, h6, h7]
.map(v => (v >>> 0).toString(16).padStart(8, '0'))
.join('')
}

22
admin-ui/src/main.tsx Normal file
View File

@@ -0,0 +1,22 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'
import './index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5000,
refetchOnWindowFocus: false,
},
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>,
)

311
admin-ui/src/types/api.ts Normal file
View File

@@ -0,0 +1,311 @@
// 凭据状态响应
export interface CredentialsStatusResponse {
total: number
available: number
credentials: CredentialStatusItem[]
}
// 单个凭据状态
export interface CredentialStatusItem {
id: number
priority: number
disabled: boolean
failureCount: number
expiresAt: string | null
authMethod: string | null
hasProfileArn: boolean
accountEmail: string | null
email?: string
refreshTokenHash?: string
// ===== 统计(可持久化) =====
callsTotal: number
callsOk: number
callsErr: number
inputTokensTotal: number
outputTokensTotal: number
lastCallAt: string | null
lastSuccessAt: string | null
lastErrorAt: string | null
lastError: string | null
// ===== upstream 字段 =====
successCount: number
lastUsedAt: string | null
hasProxy: boolean
proxyUrl?: string
/** 凭据级 Region用于 Token 刷新) */
region: string | null
/** 凭据级 API Region单独覆盖 API 请求) */
apiRegion: string | null
}
// 余额响应
export interface BalanceResponse {
id: number
subscriptionTitle: string | null
currentUsage: number
usageLimit: number
remaining: number
usagePercentage: number
nextResetAt: number | null
}
// 缓存余额信息
export interface CachedBalanceInfo {
id: number
remaining: number
cachedAt: number // Unix 毫秒时间戳
ttlSecs: number
}
// 缓存余额响应
export interface CachedBalancesResponse {
balances: CachedBalanceInfo[]
}
// 成功响应
export interface SuccessResponse {
success: boolean
message: string
}
// ===== 统计(可持久化) =====
export interface StatsBucket {
// 按日YYYY-MM-DD按模型model id
key: string
callsTotal: number
callsOk: number
callsErr: number
inputTokensTotal: number
outputTokensTotal: number
lastCallAt: string | null
lastSuccessAt: string | null
lastErrorAt: string | null
lastError: string | null
}
export interface CredentialStatsResponse {
id: number
callsTotal: number
callsOk: number
callsErr: number
inputTokensTotal: number
outputTokensTotal: number
lastCallAt: string | null
lastSuccessAt: string | null
lastErrorAt: string | null
lastError: string | null
byDay: StatsBucket[]
byModel: StatsBucket[]
}
// 错误响应
export interface AdminErrorResponse {
error: {
type: string
message: string
}
}
// 请求类型
export interface SetDisabledRequest {
disabled: boolean
}
export interface SetPriorityRequest {
priority: number
}
// 添加凭据请求
export interface AddCredentialRequest {
refreshToken: string
authMethod?: 'social' | 'idc'
clientId?: string
clientSecret?: string
priority?: number
/** Region用于 Token 刷新及默认 API 请求),可被 apiRegion 单独覆盖 */
region?: string
/** 单独覆盖 API 请求使用的 region */
apiRegion?: string
machineId?: string
proxyUrl?: string
proxyUsername?: string
proxyPassword?: string
}
// 添加凭据响应
export interface AddCredentialResponse {
success: boolean
message: string
credentialId: number
email?: string
}
// ===== 账号信息(套餐/用量/邮箱等) =====
export interface CreditBonus {
code: string
name: string
current: number
limit: number
expiresAt: string | null
}
export interface CreditsResourceDetail {
displayName: string | null
displayNamePlural: string | null
resourceType: string | null
currency: string | null
unit: string | null
overageRate: number | null
overageCap: number | null
}
export interface CreditsUsageSummary {
current: number
limit: number
baseCurrent: number
baseLimit: number
freeTrialCurrent: number
freeTrialLimit: number
freeTrialExpiry: string | null
bonuses: CreditBonus[]
nextResetDate: string | null
overageEnabled: boolean | null
resourceDetail: CreditsResourceDetail | null
}
export interface AccountSubscriptionDetails {
rawType: string | null
managementTarget: string | null
upgradeCapability: string | null
overageCapability: string | null
}
export interface ResourceUsageSummary {
resourceType: string | null
displayName: string | null
unit: string | null
currency: string | null
current: number
limit: number
}
export interface UsageAndLimitsResponse {
userInfo: { email: string | null; userId: string | null } | null
subscriptionInfo:
| {
type: string | null
subscriptionTitle: string | null
upgradeCapability: string | null
overageCapability: string | null
subscriptionManagementTarget: string | null
}
| null
usageBreakdownList:
| Array<{
resourceType: string | null
currentUsage: number | null
currentUsageWithPrecision: number | null
usageLimit: number | null
usageLimitWithPrecision: number | null
displayName: string | null
displayNamePlural: string | null
currency: string | null
unit: string | null
overageRate: number | null
overageCap: number | null
freeTrialInfo:
| {
usageLimit: number | null
usageLimitWithPrecision: number | null
currentUsage: number | null
currentUsageWithPrecision: number | null
freeTrialExpiry: string | null
freeTrialStatus: string | null
}
| null
bonuses:
| Array<{
bonusCode: string | null
displayName: string | null
usageLimit: number | null
usageLimitWithPrecision: number | null
currentUsage: number | null
currentUsageWithPrecision: number | null
status: string | null
expiresAt: string | null
}>
| null
}>
| null
nextDateReset: string | null
overageConfiguration: { overageEnabled: boolean | null } | null
}
export interface AccountAggregateInfo {
email: string | null
userId: string | null
idp: string | null
status: string | null
featureFlags: string[] | null
subscriptionTitle: string | null
subscriptionType: string
subscription: AccountSubscriptionDetails
usage: CreditsUsageSummary
resources: ResourceUsageSummary[]
rawUsage: UsageAndLimitsResponse
}
export interface CredentialAccountInfoResponse {
id: number
account: AccountAggregateInfo
}
// ============ 批量导入 token.json ============
// 官方 token.json 格式(用于解析导入)
export interface TokenJsonItem {
provider?: string
refreshToken?: string
clientId?: string
clientSecret?: string
authMethod?: string
priority?: number
region?: string
machineId?: string
}
// 批量导入请求
export interface ImportTokenJsonRequest {
dryRun?: boolean
items: TokenJsonItem | TokenJsonItem[]
}
// 导入动作
export type ImportAction = 'added' | 'skipped' | 'invalid'
// 单项导入结果
export interface ImportItemResult {
index: number
fingerprint: string
action: ImportAction
reason?: string
credentialId?: number
}
// 导入汇总
export interface ImportSummary {
parsed: number
added: number
skipped: number
invalid: number
}
// 批量导入响应
export interface ImportTokenJsonResponse {
summary: ImportSummary
items: ImportItemResult[]
}

View File

@@ -0,0 +1,53 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class',
content: [
'./index.html',
'./src/**/*.{js,ts,jsx,tsx}',
],
theme: {
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)',
},
},
},
plugins: [],
}

28
admin-ui/vite.config.ts Normal file
View File

@@ -0,0 +1,28 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
export default defineConfig({
plugins: [react()],
base: '/admin/',
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
})