🎨 增加并发访问
This commit is contained in:
480
admin-ui/bun.lock
Normal file
480
admin-ui/bun.lock
Normal 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
13
admin-ui/index.html
Normal 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>
|
||||
6
admin-ui/postcss.config.js
Normal file
6
admin-ui/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
admin-ui/public/vite.svg
Normal file
1
admin-ui/public/vite.svg
Normal 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
37
admin-ui/src/App.tsx
Normal 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
|
||||
147
admin-ui/src/api/credentials.ts
Normal file
147
admin-ui/src/api/credentials.ts
Normal 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
|
||||
}
|
||||
279
admin-ui/src/components/add-credential-dialog.tsx
Normal file
279
admin-ui/src/components/add-credential-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
106
admin-ui/src/components/balance-dialog.tsx
Normal file
106
admin-ui/src/components/balance-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
434
admin-ui/src/components/batch-import-dialog.tsx
Normal file
434
admin-ui/src/components/batch-import-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
152
admin-ui/src/components/batch-verify-dialog.tsx
Normal file
152
admin-ui/src/components/batch-verify-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
476
admin-ui/src/components/credential-card.tsx
Normal file
476
admin-ui/src/components/credential-card.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
700
admin-ui/src/components/dashboard.tsx
Normal file
700
admin-ui/src/components/dashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
671
admin-ui/src/components/import-token-json-dialog.tsx
Normal file
671
admin-ui/src/components/import-token-json-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
491
admin-ui/src/components/kam-import-dialog.tsx
Normal file
491
admin-ui/src/components/kam-import-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
62
admin-ui/src/components/login-page.tsx
Normal file
62
admin-ui/src/components/login-page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
admin-ui/src/components/ui/badge.tsx
Normal file
39
admin-ui/src/components/ui/badge.tsx
Normal 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 }
|
||||
55
admin-ui/src/components/ui/button.tsx
Normal file
55
admin-ui/src/components/ui/button.tsx
Normal 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 }
|
||||
78
admin-ui/src/components/ui/card.tsx
Normal file
78
admin-ui/src/components/ui/card.tsx
Normal 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 }
|
||||
28
admin-ui/src/components/ui/checkbox.tsx
Normal file
28
admin-ui/src/components/ui/checkbox.tsx
Normal 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 }
|
||||
119
admin-ui/src/components/ui/dialog.tsx
Normal file
119
admin-ui/src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
24
admin-ui/src/components/ui/input.tsx
Normal file
24
admin-ui/src/components/ui/input.tsx
Normal 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 }
|
||||
35
admin-ui/src/components/ui/progress.tsx
Normal file
35
admin-ui/src/components/ui/progress.tsx
Normal 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 }
|
||||
25
admin-ui/src/components/ui/sonner.tsx
Normal file
25
admin-ui/src/components/ui/sonner.tsx
Normal 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 }
|
||||
26
admin-ui/src/components/ui/switch.tsx
Normal file
26
admin-ui/src/components/ui/switch.tsx
Normal 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 }
|
||||
175
admin-ui/src/hooks/use-credentials.ts
Normal file
175
admin-ui/src/hooks/use-credentials.ts
Normal 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
90
admin-ui/src/index.css
Normal 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;
|
||||
}
|
||||
44
admin-ui/src/lib/format.ts
Normal file
44
admin-ui/src/lib/format.ts
Normal 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)} 天`
|
||||
}
|
||||
7
admin-ui/src/lib/storage.ts
Normal file
7
admin-ui/src/lib/storage.ts
Normal 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
202
admin-ui/src/lib/utils.ts
Normal 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 API(crypto.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
22
admin-ui/src/main.tsx
Normal 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
311
admin-ui/src/types/api.ts
Normal 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[]
|
||||
}
|
||||
53
admin-ui/tailwind.config.js
Normal file
53
admin-ui/tailwind.config.js
Normal 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
28
admin-ui/vite.config.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user